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:
@@ -38,6 +38,10 @@ export type RecordsFilter = {
|
|||||||
bbox?: string
|
bbox?: string
|
||||||
/** GeoJSON Polygon as raw object — serialized as JSON в query string. */
|
/** GeoJSON Polygon as raw object — serialized as JSON в query string. */
|
||||||
polygon?: GeoJSON.Polygon
|
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 = (
|
export const recordsQuery = (
|
||||||
@@ -52,11 +56,13 @@ export const recordsQuery = (
|
|||||||
scopeCsv,
|
scopeCsv,
|
||||||
filter?.bbox ?? null,
|
filter?.bbox ?? null,
|
||||||
filter?.polygon ? JSON.stringify(filter.polygon) : null,
|
filter?.polygon ? JSON.stringify(filter.polygon) : null,
|
||||||
|
filter?.at ?? null,
|
||||||
] as const,
|
] as const,
|
||||||
queryFn: async (): Promise<FlattenedRecord[]> => {
|
queryFn: async (): Promise<FlattenedRecord[]> => {
|
||||||
const params: Record<string, string> = { as_scope: scopeCsv }
|
const params: Record<string, string> = { as_scope: scopeCsv }
|
||||||
if (filter?.bbox) params.bbox = filter.bbox
|
if (filter?.bbox) params.bbox = filter.bbox
|
||||||
if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon)
|
if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon)
|
||||||
|
if (filter?.at) params.at = filter.at
|
||||||
const { data } = await apiClient.get<FlattenedRecord[]>(
|
const { data } = await apiClient.get<FlattenedRecord[]>(
|
||||||
`/${dictionaryName}/records`,
|
`/${dictionaryName}/records`,
|
||||||
{ params },
|
{ params },
|
||||||
|
|||||||
@@ -257,6 +257,12 @@ i18n
|
|||||||
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
||||||
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
||||||
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
'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 close (Phase 3) ===
|
||||||
'cascade.title': 'Каскадное закрытие',
|
'cascade.title': 'Каскадное закрытие',
|
||||||
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
||||||
@@ -602,6 +608,12 @@ i18n
|
|||||||
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
||||||
'lineage.refs.stale': 'refreshed {{minutes}} min ago',
|
'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.',
|
'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 close (Phase 3) ===
|
||||||
'cascade.title': 'Cascade close',
|
'cascade.title': 'Cascade close',
|
||||||
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ type DictSearch = {
|
|||||||
q?: string
|
q?: string
|
||||||
scopes?: string
|
scopes?: string
|
||||||
bbox?: string
|
bbox?: string
|
||||||
|
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
||||||
|
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
||||||
|
at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
||||||
@@ -65,6 +68,10 @@ const validateSearch = (raw: Record<string, unknown>): DictSearch => {
|
|||||||
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
|
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
|
||||||
out.bbox = 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +98,23 @@ function DictionaryDetail() {
|
|||||||
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
|
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
|
||||||
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
|
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
|
||||||
|
|
||||||
const filter: RecordsFilter | undefined = aoi
|
// Time-travel state — keep ISO string in URL так чтобы user мог share link
|
||||||
? aoi.kind === 'bbox'
|
// "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now().
|
||||||
? { bbox: aoi.bboxCsv }
|
const timeTravelAt: string | undefined = urlSearch.at
|
||||||
: { polygon: aoi.geojson }
|
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
|
: undefined
|
||||||
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
||||||
const createMut = useCreateRecord(name)
|
const createMut = useCreateRecord(name)
|
||||||
@@ -108,6 +128,7 @@ function DictionaryDetail() {
|
|||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
|
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||||||
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
||||||
* на close, или явным action из record menu. */
|
* на close, или явным action из record menu. */
|
||||||
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
||||||
@@ -415,6 +436,15 @@ function DictionaryDetail() {
|
|||||||
>
|
>
|
||||||
{t('aoi.button')}
|
{t('aoi.button')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
|
||||||
|
disabled={!detailQuery.data}
|
||||||
|
onClick={() => setTimeTravelOpen((v) => !v)}
|
||||||
|
aria-pressed={Boolean(timeTravelAt)}
|
||||||
|
>
|
||||||
|
{t('timeTravel.button')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
leftIcon={<GearIcon weight="bold" size={16} />}
|
||||||
@@ -459,6 +489,75 @@ function DictionaryDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Time-travel picker — раскрывается по клику на toolbar button.
|
||||||
|
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
||||||
|
ambient banner справа от input'а. Phase v1 stretch. */}
|
||||||
|
{timeTravelOpen && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 text-sm">
|
||||||
|
<span className="text-xs text-carbon/70 font-secondary uppercase tracking-label">
|
||||||
|
{t('timeTravel.label')}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono"
|
||||||
|
value={
|
||||||
|
timeTravelAt
|
||||||
|
? // datetime-local не принимает Z/offset — конвертируем в local.
|
||||||
|
new Date(timeTravelAt).toISOString().slice(0, 16)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setTimeTravelAt(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeTravelAt(new Date(e.target.value).toISOString())
|
||||||
|
}}
|
||||||
|
aria-label={t('timeTravel.label')}
|
||||||
|
/>
|
||||||
|
{timeTravelAt && (
|
||||||
|
<>
|
||||||
|
<span className="text-2xs text-carbon/70 font-mono">
|
||||||
|
{new Date(timeTravelAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-ultramarain hover:underline"
|
||||||
|
onClick={() => setTimeTravelAt(undefined)}
|
||||||
|
>
|
||||||
|
{t('timeTravel.clear')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-carbon/60 hover:underline ml-auto"
|
||||||
|
onClick={() => setTimeTravelOpen(false)}
|
||||||
|
>
|
||||||
|
{t('timeTravel.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
|
||||||
|
{timeTravelAt && !timeTravelOpen && (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 text-sm">
|
||||||
|
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-orbit shrink-0" />
|
||||||
|
<span className="font-mono text-xs text-carbon/80">
|
||||||
|
{t('timeTravel.viewing', {
|
||||||
|
date: new Date(timeTravelAt).toLocaleString(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-ultramarain hover:underline ml-auto"
|
||||||
|
onClick={() => setTimeTravelAt(undefined)}
|
||||||
|
>
|
||||||
|
{t('timeTravel.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
|
||||||
{recordsResult.error && (
|
{recordsResult.error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user