From 08405fecf7951ecdcc98cbdc2b87dd661e6f9fa9 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 12:39:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20time-travel=20UI=20=E2=80=94?= =?UTF-8?q?=20view=20dictionary=20as-of=20any=20past=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)". Backend уже поддерживает ?at= в read-api endpoints (DictionaryReadController): findActiveAt(dict, key, at) возвращает active version на этот момент. Был только UI gap. UI: - New toolbar button "Просмотр на дату" с ClockCounterClockwiseIcon. aria-pressed когда timeTravelAt активен. - Datetime picker раскрывается inline (не модал — quick interactions). Native , конвертируется ↔ ISO. - Active state ambient banner (orbit/8 background) если ?at= задан + picker закрыт — показывает "Просмотр на: {date}" + "Сейчас" клир link. - "Закрыть" кнопка для picker'а; "Сейчас" сбрасывает at. URL searchParams: - DictSearch type расширен `at?: string` (ISO datetime). - validateSearch проверяет `Date.parse(at)` — invalid timestamps drop'ятся. - Share-friendly: "ordinis.k8s.265.../dictionaries/spacecraft?at=2025-12-31T00:00:00Z" работает per F5 + ссылка коллеге. Records query: - RecordsFilter type +at?: string. - recordsQuery передаёт ?at=… backend'у в дополнение к as_scope/bbox/polygon. - queryKey включает at — switching между моментами trigger'ит refetch. i18n RU/EN: timeTravel.{button,label,viewing,clear,close}. Verify: - pnpm tsc --noEmit: clean. - pnpm test (vitest): 89/89 PASS. - pnpm build: clean. Backend смену не требует — DictionaryReadController.list() уже принимал @RequestParam(value = "at") OffsetDateTime at и роут'ил в RecordReadService. --- ordinis-admin-ui/src/api/queries.ts | 6 + ordinis-admin-ui/src/i18n.ts | 12 ++ .../src/routes/dictionaries.$name.tsx | 109 +++++++++++++++++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index fbd262c..5ced8f8 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -38,6 +38,10 @@ export type RecordsFilter = { bbox?: string /** GeoJSON Polygon as raw object — serialized as JSON в query string. */ polygon?: GeoJSON.Polygon + /** ISO datetime для time-travel запроса (CEO plan time-travel UI). + * Backend (read-api) findActiveAt(dict, key, at) возвращает active version + * на этот момент. Без этого param'а — now(). */ + at?: string } export const recordsQuery = ( @@ -52,11 +56,13 @@ export const recordsQuery = ( scopeCsv, filter?.bbox ?? null, filter?.polygon ? JSON.stringify(filter.polygon) : null, + filter?.at ?? null, ] as const, queryFn: async (): Promise => { const params: Record = { as_scope: scopeCsv } if (filter?.bbox) params.bbox = filter.bbox if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon) + if (filter?.at) params.at = filter.at const { data } = await apiClient.get( `/${dictionaryName}/records`, { params }, diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index aafeae1..0eabfe1 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -257,6 +257,12 @@ i18n 'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике', 'lineage.refs.stale': 'обновлено {{minutes}} мин назад', 'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.', + // === Time-travel (CEO plan v1 stretch) === + 'timeTravel.button': 'Просмотр на дату', + 'timeTravel.label': 'Активная версия на момент:', + 'timeTravel.viewing': 'Просмотр на момент: {{date}}', + 'timeTravel.clear': 'Сейчас', + 'timeTravel.close': 'Закрыть', // === Cascade close (Phase 3) === 'cascade.title': 'Каскадное закрытие', 'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.', @@ -602,6 +608,12 @@ i18n 'lineage.refs.openSourceRecord': 'Open referencing record {{key}}', 'lineage.refs.stale': 'refreshed {{minutes}} min ago', 'lineage.refs.staleHint': 'Data from materialized view, refreshed once per minute. Records closed between refreshes are filtered by valid_to.', + // === Time-travel (CEO plan v1 stretch) === + 'timeTravel.button': 'View as of date', + 'timeTravel.label': 'Active version as of:', + 'timeTravel.viewing': 'Viewing as of: {{date}}', + 'timeTravel.clear': 'Now', + 'timeTravel.close': 'Close', // === Cascade close (Phase 3) === 'cascade.title': 'Cascade close', 'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index dfdd78c..c89b5f7 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -48,6 +48,9 @@ type DictSearch = { q?: string scopes?: string bbox?: string + /** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend + * findActiveAt(at) возвращает active version на этот момент вместо now(). */ + at?: string } const SCOPE_VALUES: ReadonlySet = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) @@ -65,6 +68,10 @@ const validateSearch = (raw: Record): DictSearch => { if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) { out.bbox = raw.bbox } + // ISO datetime check: 2026-05-08T12:00:00Z или 2026-05-08T12:00:00+03:00. + if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) { + out.at = raw.at + } return out } @@ -91,11 +98,24 @@ function DictionaryDetail() { // setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL). const [aoi, setAoi] = useState(() => bboxToAoi(urlSearch.bbox)) - const filter: RecordsFilter | undefined = aoi - ? aoi.kind === 'bbox' - ? { bbox: aoi.bboxCsv } - : { polygon: aoi.geojson } - : undefined + // Time-travel state — keep ISO string in URL так чтобы user мог share link + // "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now(). + const timeTravelAt: string | undefined = urlSearch.at + const setTimeTravelAt = (next: string | undefined) => { + void navigate({ + search: (prev) => ({ ...prev, at: next || undefined }), + replace: true, + }) + } + + const filter: RecordsFilter | undefined = + aoi || timeTravelAt + ? { + ...(aoi?.kind === 'bbox' ? { bbox: aoi.bboxCsv } : {}), + ...(aoi?.kind === 'polygon' ? { polygon: aoi.geojson } : {}), + ...(timeTravelAt ? { at: timeTravelAt } : {}), + } + : undefined const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter) const createMut = useCreateRecord(name) const updateMut = useUpdateRecord(name) @@ -108,6 +128,7 @@ function DictionaryDetail() { const [schemaEditOpen, setSchemaEditOpen] = useState(false) const [historyKey, setHistoryKey] = useState(undefined) const [aoiOpen, setAoiOpen] = useState(false) + const [timeTravelOpen, setTimeTravelOpen] = useState(false) /** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409 * на close, или явным action из record menu. */ const [cascadeKey, setCascadeKey] = useState(undefined) @@ -415,6 +436,15 @@ function DictionaryDetail() { > {t('aoi.button')} + + + )} + + + )} + + {/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */} + {timeTravelAt && !timeTravelOpen && ( +
+ + + {t('timeTravel.viewing', { + date: new Date(timeTravelAt).toLocaleString(), + })} + + +
+ )} + {recordsResult.isLoading && } {recordsResult.error && (