From 9199f380e1c52ab49384bb5813b38108e34a4ac8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 15 May 2026 21:28:29 +0300 Subject: [PATCH] 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 {