feat(admin-ui): time-travel UI — view dictionary as-of any past date

CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)".

Backend уже поддерживает ?at=<ISO> в 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 <input type=datetime-local>, конвертируется ↔ 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.
This commit is contained in:
Zimin A.N.
2026-05-08 12:39:53 +03:00
parent 0a8275ef4c
commit 08405fecf7
3 changed files with 122 additions and 5 deletions
+6
View File
@@ -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<FlattenedRecord[]> => {
const params: Record<string, string> = { 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<FlattenedRecord[]>(
`/${dictionaryName}/records`,
{ params },