Merge branch 'fix/scheduled-marks-banner-prefill' into 'main'
fix(records): scheduled marks + non-empty banner + editor validFrom prefill See merge request 2-6/2-6-4/terravault/ordinis!224
This commit is contained in:
@@ -770,4 +770,6 @@ export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
||||
export type ScheduledRecordsSummary = {
|
||||
count: number
|
||||
nearestValidFrom: string | null
|
||||
/** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */
|
||||
upcomingValidFroms: string[]
|
||||
}
|
||||
|
||||
@@ -109,25 +109,22 @@ export function TimeTravelModal({
|
||||
isAtNow0 ? undefined : { at: new Date(picked).toISOString() },
|
||||
)
|
||||
|
||||
// Range: от min(marks, createdAt) до max(now+30d, future marks)+padding.
|
||||
// Range: derived purely from real data — marks (record validFroms,
|
||||
// включая upcoming scheduled, передаются parent'ом), createdAt, now.
|
||||
// No arbitrary future window: max берётся max(marks) — last real change
|
||||
// point. User feedback: «крайняя дата должна быть исходя из реальной в
|
||||
// этом словаре».
|
||||
//
|
||||
// Future support (fix paired с TimeTravelPicker !220): если у dict есть
|
||||
// scheduled records (validFrom > now) или user navigated к future date,
|
||||
// slider должен дотягиваться. Раньше max = now + 1day pad — record с
|
||||
// validFrom=18.05 был недостижим если сейчас 15.05.
|
||||
//
|
||||
// Default future window = 30 days, match'ит TimeTravelPicker.tsx logic.
|
||||
// Если все marks в прошлом — max=now (user всё равно может перейти к now).
|
||||
// Если есть scheduled validFrom > now — max тянется до furthest scheduled.
|
||||
const createdMs = Date.parse(detail.createdAt)
|
||||
const range = useMemo(() => {
|
||||
const DAY_MS = 86_400_000
|
||||
const FUTURE_WINDOW_MS = 30 * DAY_MS
|
||||
const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - DAY_MS
|
||||
// Push max forward: at least now + 30 days, и further если есть future marks.
|
||||
const maxMark = marks.length > 0 ? Math.max(...marks, now) : now
|
||||
const effectiveMax = Math.max(maxMark, now + FUTURE_WINDOW_MS)
|
||||
const span = effectiveMax - minMark
|
||||
const span = maxMark - minMark
|
||||
const pad = Math.max(span * 0.04, DAY_MS)
|
||||
return { min: minMark - pad, max: effectiveMax + pad }
|
||||
return { min: minMark - pad, max: maxMark + pad }
|
||||
}, [marks, createdMs, now])
|
||||
|
||||
// Sync picked when value prop changes while modal open.
|
||||
|
||||
@@ -39,20 +39,18 @@ export function TimeTravelPicker({
|
||||
return Number.isNaN(d.getTime()) ? undefined : d
|
||||
}, [value])
|
||||
|
||||
// Range: from min(marks, value) − padding до max(now, future marks) + padding.
|
||||
// Range: derived purely from real data — marks (record validFrom timestamps
|
||||
// включая upcoming scheduled), createdAt, и picked value. Никаких arbitrary
|
||||
// future windows: если в словаре нет записей на 18.05 — slider туда не
|
||||
// тянется. User feedback: «крайняя дата должна быть исходя из реальной в
|
||||
// этом словаре».
|
||||
//
|
||||
// Future support: если у dict'а есть records с validFrom > now (scheduled),
|
||||
// или user navigated к future date (asOf=18.05 для preview new record),
|
||||
// slider должен дотягиваться до этой даты. Раньше max = now + 1day pad —
|
||||
// scheduled records на дату дальше 1 день были недоступны через slider
|
||||
// (user не мог время-travel'ить чтобы их увидеть).
|
||||
//
|
||||
// Default future window = 30 days, чтобы планирование на месяц вперёд
|
||||
// (типичный business horizon для NSI updates) было без ручного URL hack.
|
||||
// Каноничный max берётся max(marks) — это последняя «интересная точка»
|
||||
// (либо last existing record validFrom, либо furthest scheduled validFrom).
|
||||
// Если marks пусты — fallback на now + 1 day pad чтобы slider не вырождался.
|
||||
const range = useMemo(() => {
|
||||
const now = Date.now()
|
||||
const DAY_MS = 86_400_000
|
||||
const FUTURE_WINDOW_MS = 30 * DAY_MS
|
||||
|
||||
let minMark = marks.length > 0 ? Math.min(...marks) : now - 365 * DAY_MS
|
||||
let maxMark = marks.length > 0 ? Math.max(...marks) : now
|
||||
@@ -60,8 +58,9 @@ export function TimeTravelPicker({
|
||||
minMark = Math.min(minMark, valueDate.getTime())
|
||||
maxMark = Math.max(maxMark, valueDate.getTime())
|
||||
}
|
||||
// Push max forward: at least now + 30 days, и further если есть будущие marks.
|
||||
const effectiveMax = Math.max(maxMark, now + FUTURE_WINDOW_MS)
|
||||
// Ensure max ≥ now чтобы «сейчас» всегда был достижим, even если все
|
||||
// marks в прошлом (закрытый dict).
|
||||
const effectiveMax = Math.max(maxMark, now)
|
||||
const span = effectiveMax - minMark
|
||||
const pad = Math.max(span * 0.05, DAY_MS) // ≥1 день
|
||||
return { min: minMark - pad, max: effectiveMax + pad }
|
||||
|
||||
@@ -295,15 +295,22 @@ function DictionaryDetail() {
|
||||
|
||||
// Уникальные validFrom timestamps для timeline marks. Используем raw
|
||||
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
|
||||
// фильтров.
|
||||
// фильтров. PLUS scheduled upcoming validFroms — user feedback: «на таймтревел
|
||||
// отметки нужны где записи есть или меняются, а не просто +30 и ищи потом».
|
||||
// После !220 расширили range до +30 дней, теперь добавляем future record
|
||||
// validFroms как marks чтобы slider показывал «вот тут запись появится».
|
||||
const recordTimestamps = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const r of recordsResult.data ?? []) {
|
||||
const ts = new Date(r.validFrom).getTime()
|
||||
if (!Number.isNaN(ts)) set.add(ts)
|
||||
}
|
||||
for (const iso of scheduledSummary.data?.upcomingValidFroms ?? []) {
|
||||
const ts = new Date(iso).getTime()
|
||||
if (!Number.isNaN(ts)) set.add(ts)
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a - b)
|
||||
}, [recordsResult.data])
|
||||
}, [recordsResult.data, scheduledSummary.data?.upcomingValidFroms])
|
||||
|
||||
// Column filters — per-column value match (e.g. status='operational',
|
||||
// operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
|
||||
@@ -1011,6 +1018,31 @@ function DictionaryDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{recordsResult.data && recordsResult.data.length > 0
|
||||
&& scheduledSummary.data && scheduledSummary.data.count > 0
|
||||
&& scheduledSummary.data.nearestValidFrom && (
|
||||
// Info banner для non-empty listing когда есть scheduled records.
|
||||
// User feedback: «нету отметки что есть запись в будущем» — table
|
||||
// shows только currently active, юзер не видит upcoming. Banner
|
||||
// duplicates EmptyState hint в non-empty case + CTA jump-to-date.
|
||||
<div className="flex items-center gap-3 px-3 py-2 mb-2 rounded-sm border border-warn bg-warn-bg text-body">
|
||||
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
|
||||
<span className="text-cell text-ink">
|
||||
{t('dict.scheduled.hint', {
|
||||
count: scheduledSummary.data.count,
|
||||
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
|
||||
className="text-cell text-accent hover:underline ml-auto"
|
||||
>
|
||||
{t('dict.scheduled.cta')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordsResult.data && recordsResult.data.length > 0 && (
|
||||
<>
|
||||
{/* Records toolbar — attached к table panel (tinted top section,
|
||||
@@ -1332,11 +1364,14 @@ function DictionaryDetail() {
|
||||
businessKey: rawRecordQuery.data.businessKey,
|
||||
data: rawRecordQuery.data.data,
|
||||
// Edit в bitemporal model = «создаём новую версию с этого момента,
|
||||
// старая закрывается». validFrom оставляем пустым — handleSubmit
|
||||
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
|
||||
// если user долго держит drawer открытым).
|
||||
// старая закрывается». Prefill nowIsoLocal() чтобы юзер видел
|
||||
// конкретную дату («сейчас» как точка отсчёта), а не пустое поле.
|
||||
// User feedback: "Действует с показывает ничего, а даты заданы" —
|
||||
// empty field выглядит как баг. handleSubmit всё равно подставит
|
||||
// свежий nowIsoLocal() если поле не тронуто (review #5 защита от
|
||||
// stale значения если drawer долго открыт).
|
||||
// validTo пустой = бессрочно (или до следующей правки).
|
||||
validFrom: '',
|
||||
validFrom: nowIsoLocal(),
|
||||
validTo: '',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
+25
@@ -203,4 +203,29 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
|
||||
@Param("dictionaryId") UUID dictionaryId,
|
||||
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||
@Param("now") OffsetDateTime now);
|
||||
|
||||
/**
|
||||
* Список validFrom timestamps для всех scheduled records (validFrom > now,
|
||||
* validTo > now) — sorted asc, capped в service слое.
|
||||
*
|
||||
* <p>Используется TimeTravel slider marks: показывает «вот тут запись появится»
|
||||
* pinpoints вместо blind drag вправо после !220 +30day window fix.
|
||||
* User feedback: «отметки нужны где записи есть или меняются».
|
||||
*
|
||||
* <p>{@link org.springframework.data.domain.Pageable Pageable} caller'а
|
||||
* ограничивает (top 50 typical) — DB sort + limit на index дешевле полной выборки.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT r.validFrom FROM DictionaryRecord r
|
||||
WHERE r.dictionaryId = :dictionaryId
|
||||
AND r.dataScope IN :allowedScopes
|
||||
AND r.validFrom > :now
|
||||
AND r.validTo > :now
|
||||
ORDER BY r.validFrom ASC
|
||||
""")
|
||||
List<OffsetDateTime> findUpcomingValidFroms(
|
||||
@Param("dictionaryId") UUID dictionaryId,
|
||||
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||
@Param("now") OffsetDateTime now,
|
||||
org.springframework.data.domain.Pageable pageable);
|
||||
}
|
||||
|
||||
+18
-8
@@ -1,22 +1,32 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Empty-state hint payload: сколько записей запланировано в будущем и
|
||||
* на какую ближайшую дату. Используется admin-UI чтобы показать
|
||||
* «У словаря есть N запланированных, ближайшая на DD.MM» вместе с
|
||||
* CTA «Перейти к дате» (set time-travel).
|
||||
* Empty-state hint payload + future-marks data: сколько записей запланировано,
|
||||
* на какую ближайшую дату, и список upcoming validFrom timestamps для
|
||||
* TimeTravel slider marks.
|
||||
*
|
||||
* <p>{@code count == 0} → {@code nearestValidFrom == null}; frontend
|
||||
* не показывает hint в этом случае.
|
||||
* <ul>
|
||||
* <li>{@code count} — total scheduled (validFrom > now AND validTo > now)</li>
|
||||
* <li>{@code nearestValidFrom} — earliest upcoming, для CTA «Перейти к дате»</li>
|
||||
* <li>{@code upcomingValidFroms} — sorted asc, capped (50 by default).
|
||||
* Frontend merges as marks на time-travel slider — fixes UX где
|
||||
* slider extended до +30d, но без отметок где реально что-то происходит</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code count == 0} → nearest=null, upcoming=empty list.
|
||||
*
|
||||
* <p>Scope filtering: same view permissions that {@code listActive}
|
||||
* использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а.
|
||||
*/
|
||||
public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) {
|
||||
public record ScheduledRecordsSummary(
|
||||
long count,
|
||||
OffsetDateTime nearestValidFrom,
|
||||
List<OffsetDateTime> upcomingValidFroms) {
|
||||
|
||||
public static ScheduledRecordsSummary empty() {
|
||||
return new ScheduledRecordsSummary(0L, null);
|
||||
return new ScheduledRecordsSummary(0L, null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -209,7 +209,14 @@ public class RecordReadService {
|
||||
long count = row[0] == null ? 0L : ((Number) row[0]).longValue();
|
||||
OffsetDateTime nearest = (OffsetDateTime) row[1];
|
||||
if (count == 0L || nearest == null) return ScheduledRecordsSummary.empty();
|
||||
return new ScheduledRecordsSummary(count, nearest);
|
||||
|
||||
// Top-50 upcoming validFrom timestamps для TimeTravel slider marks.
|
||||
// 50 — баланс между UX (видно все основные точки изменений)
|
||||
// и payload size (50 ISO timestamps ≈ 1.6KB).
|
||||
List<OffsetDateTime> upcoming = recordRepository.findUpcomingValidFroms(
|
||||
def.getId(), allowedScopes, now,
|
||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
return new ScheduledRecordsSummary(count, nearest, upcoming);
|
||||
}
|
||||
|
||||
public static class ScopeAccessDeniedException extends RuntimeException {
|
||||
|
||||
Reference in New Issue
Block a user