From 9199f380e1c52ab49384bb5813b38108e34a4ac8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 15 May 2026 21:28:29 +0300 Subject: [PATCH 1/2] fix(records): scheduled marks on time-travel + non-empty banner + editor validFrom prefill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три связанных UX бага после v2.31.0 release: 1. **TimeTravel marks**: после !220 slider extended до now+30d, но отметок в future window не было → blind drag. Backend теперь возвращает upcomingValidFroms (top 50) в /scheduled-summary; frontend merges как marks. 2. **Non-empty banner**: empty-state hint работает только когда список пуст. Если currently active записи есть AND ещё запланированы — пользователь не видит. Добавлен info banner над таблицей с тем же текстом + CTA. 3. **Editor "Действует с"**: edit mode оставлял поле пустым (bitemporal policy «новая версия с now»). User feedback: «показывает ничего, а даты заданы» — выглядит как баг. Prefill nowIsoLocal() — теперь юзер видит конкретную дату как точку отсчёта. --- ordinis-admin-ui/src/api/client.ts | 2 + .../src/routes/dictionaries.$name.tsx | 47 ++++++++++++++++--- .../record/DictionaryRecordRepository.java | 25 ++++++++++ .../readapi/dto/ScheduledRecordsSummary.java | 26 ++++++---- .../readapi/service/RecordReadService.java | 9 +++- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 5bdd512..57409f8 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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[] } diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 8ba2909..f405b83 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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() 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. +
+ + + {t('dict.scheduled.hint', { + count: scheduledSummary.data.count, + date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(), + })} + + +
+ )} + {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} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index 2f7a3c2..b8702b6 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -203,4 +203,29 @@ public interface DictionaryRecordRepository extends JpaRepository allowedScopes, @Param("now") OffsetDateTime now); + + /** + * Список validFrom timestamps для всех scheduled records (validFrom > now, + * validTo > now) — sorted asc, capped в service слое. + * + *

Используется TimeTravel slider marks: показывает «вот тут запись появится» + * pinpoints вместо blind drag вправо после !220 +30day window fix. + * User feedback: «отметки нужны где записи есть или меняются». + * + *

{@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 findUpcomingValidFroms( + @Param("dictionaryId") UUID dictionaryId, + @Param("allowedScopes") Collection allowedScopes, + @Param("now") OffsetDateTime now, + org.springframework.data.domain.Pageable pageable); } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java index 2a3ad44..abc8db8 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java @@ -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. * - *

{@code count == 0} → {@code nearestValidFrom == null}; frontend - * не показывает hint в этом случае. + *

    + *
  • {@code count} — total scheduled (validFrom > now AND validTo > now)
  • + *
  • {@code nearestValidFrom} — earliest upcoming, для CTA «Перейти к дате»
  • + *
  • {@code upcomingValidFroms} — sorted asc, capped (50 by default). + * Frontend merges as marks на time-travel slider — fixes UX где + * slider extended до +30d, но без отметок где реально что-то происходит
  • + *
+ * + *

{@code count == 0} → nearest=null, upcoming=empty list. * *

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 upcomingValidFroms) { public static ScheduledRecordsSummary empty() { - return new ScheduledRecordsSummary(0L, null); + return new ScheduledRecordsSummary(0L, null, List.of()); } } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java index 821fb0a..2f4368f 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java @@ -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 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 { From d0ef822b9e2efb35dc8f63496a8e3da9521d0a4b Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 15 May 2026 21:30:08 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(timetravel):=20drop=20arbitrary=20+30d?= =?UTF-8?q?=20max=20=E2=80=94=20use=20real=20marks=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: «крайняя дата должна быть исходя из реальной в этом словаре». Раньше slider всегда extended до now + 30d даже без причины — если нет scheduled записей на 18.05, slider всё равно тянулся туда, пользователь блуждал по пустоте. Теперь max = max(marks) — last real change point (record validFrom включая upcoming scheduled, которые backend возвращает в /scheduled-summary). Если все marks в прошлом — max=now (always reachable). Если есть future scheduled на 18.05 — slider тянется ровно туда, не дальше. Touches: TimeTravelPicker.tsx (inline editor) + TimeTravelModal.tsx (modal). --- .../components/timetravel/TimeTravelModal.tsx | 21 ++++++++--------- .../timetravel/TimeTravelPicker.tsx | 23 +++++++++---------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx index 5587ac7..86547ba 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx @@ -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. diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx index 123bbfc..e065797 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx @@ -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 }