import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react' import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import type { ChangelogEntry, DictionaryDetail, FlattenedRecord } from '@/api/client' import { useChangelog, useChangelogDiff, useRecords } from '@/api/queries' import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta' import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal' import { UserCell } from '@/lib/useUserDisplay' import { cn } from '@/lib/utils' /** * TimeTravelModal — fullscreen-y modal per redesign prototype. * *

Layout: *

* *

Backend coverage: findActiveAt(at) уже работает (read-api). Нет * snapshots endpoint, нет diff. Поэтому version labels на slider — выводятся * из schemaVersion текущей версии (single point), а исторические marks из * record validFrom timestamps. "Открыть как readonly" просто применяет * ?at=ISO в URL — route обрабатывает. */ export type TimeTravelModalProps = { open: boolean onClose: () => void detail: DictionaryDetail /** Currently applied at-ISO (если активен readonly snapshot). */ value: string | undefined /** Apply the picked moment: opens dict as readonly (route uses ?at=). */ onApply: (iso: string) => void /** Clear at — return к live view. */ onClear: () => void /** Unique record validFrom timestamps (ms) — для slider tick marks. */ marks: number[] /** Total records на текущий момент — для tab counter. */ totalRecords: number } type StepMode = 'version' | 'day' export function TimeTravelModal({ open, onClose, detail, value, onApply, onClear, marks, totalRecords, }: TimeTravelModalProps) { const { t } = useTranslation() const now = Date.now() const valueMs = useMemo(() => { if (!value) return now const ms = Date.parse(value) return Number.isNaN(ms) ? now : ms }, [value, now]) const [picked, setPicked] = useState(valueMs) const [step, setStep] = useState('version') const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes') /** Open ChangelogDiffModal для конкретной entry — full before/after JSON. */ const [diffEntryId, setDiffEntryId] = useState(undefined) // Changelog для compare-since-picked: показываем events после picked moment. // Размер 200 покрывает типичные dict'ы (5-50 events); cursor pagination // можно добавить когда что-то будет с этим больше. const changelog = useChangelog(detail.name, 200) const eventsSincePicked = useMemo(() => { if (!changelog.data) return [] return changelog.data.entries.filter( (e) => new Date(e.publishedAt).getTime() >= picked, ) }, [changelog.data, picked]) // Find changelog entry which BROUGHT the dict to version active at `picked`: // newest entry с publishedAt <= picked. Используется в Schema tab для // "история этой версии" + diff.after schema fields. const pickedVersionEntry = useMemo(() => { if (!changelog.data) return null const ordered = [...changelog.data.entries].sort( (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), ) return ordered.find((e) => new Date(e.publishedAt).getTime() <= picked) ?? null }, [changelog.data, picked]) // Records as-of picked moment — backend findActiveAt(at) на read-api. const isAtNow0 = Math.abs(picked - now) < 60_000 const recordsQuery = useRecords( detail.name, 'PUBLIC,INTERNAL,RESTRICTED', // Если picked ≈ now, не передаём `at` — экономим лишний DB hit + cache key. isAtNow0 ? undefined : { at: new Date(picked).toISOString() }, ) // Range: от min(marks, createdAt) до now+padding. const createdMs = Date.parse(detail.createdAt) const range = useMemo(() => { const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - 86_400_000 const span = now - minMark const pad = Math.max(span * 0.04, 86_400_000) return { min: minMark - pad, max: now + pad } }, [marks, createdMs, now]) // Sync picked when value prop changes while modal open. // (Don't useEffect — пока explicitly value control.) const pickedDate = new Date(picked) const isAtNow = Math.abs(picked - now) < 60_000 // < 1 min ≈ now const targetVersion = detail.schemaVersion // backend doesn't expose snapshots const targetDiffers = !isAtNow const handlePreset = (deltaMs: number | 'create') => { if (deltaMs === 'create') { setPicked(createdMs) } else if (deltaMs === 0) { setPicked(now) } else { setPicked(now + deltaMs) } } const handleStepNav = (direction: -1 | 1) => { if (step === 'day') { setPicked((p) => Math.min(range.max, Math.max(range.min, p + direction * 86_400_000))) } else { // Version step: jump to nearest mark const sorted = [...marks].sort((a, b) => a - b) if (sorted.length === 0) return if (direction === -1) { const prev = [...sorted].reverse().find((m) => m < picked - 1000) if (prev !== undefined) setPicked(prev) } else { const next = sorted.find((m) => m > picked + 1000) if (next !== undefined) setPicked(next) } } } const handleApply = () => { if (isAtNow) { onClear() } else { onApply(new Date(picked).toISOString()) } onClose() } return (

!v && onClose()}> {/* Radix a11y: sr-only Title + Description. Visible header — custom layout ниже. */} {t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })} {t('timeTravel.description', { defaultValue: 'Сравнение текущего состояния справочника с выбранной точкой во времени.', })} {/* Header */}
Time-travel · {detail.name.toUpperCase()}

{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}

{/* Compare panels */}
{t('timeTravel.compare.point', { defaultValue: 'Точка во времени' })} {targetDiffers && ( · {t('timeTravel.compare.diff', { defaultValue: 'отличается' })} )} } version={targetVersion} ms={picked} records={totalRecords /* backend ?at= даст реальный count при заходе */} author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy} note={isAtNow ? '«current»' : undefined} highlight={targetDiffers} />
{/* Quick presets + step toggle */}
{t('timeTravel.quick', { defaultValue: 'Быстро' })}
handlePreset(0)} /> handlePreset(-7 * 86_400_000)} /> handlePreset(-30 * 86_400_000)} /> handlePreset(-365 * 86_400_000)} /> handlePreset('create')} />
{t('timeTravel.step', { defaultValue: 'Шаг' })}
setStep('version')} label="версия" /> setStep('day')} label="день" />
{/* Slider */}
{/* Tabs */}
setTab('changes')} label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })} count={eventsSincePicked.length} /> setTab('records')} label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })} count={recordsQuery.data?.length ?? totalRecords} /> setTab('schema')} label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })} />
{/* Tab content */}
{tab === 'changes' && ( setDiffEntryId(id)} /> )} {tab === 'records' && ( )} {tab === 'schema' && ( )}
{/* Per-event diff modal — fires lazy fetch /changelog/{id}/diff. */} setDiffEntryId(undefined)} dictionaryName={detail.name} entryId={diffEntryId} /> {/* Footer */}
{t('timeTravel.disclaimer', { defaultValue: 'Snapshot не меняет данные — только показывает', })}
) } function ComparePanel({ label, version, ms, records, author, note, highlight, }: { label: React.ReactNode version: string ms: number records: number author?: string note?: string highlight?: boolean }) { const d = new Date(ms) return (
{label}
v{version} {d.toLocaleDateString(undefined, { dateStyle: 'short' })} {' · '} {d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
{records} {pluralRu(records, 'запись', 'записи', 'записей')} {note && ( · {note} )} {author && ( · )}
) } function Preset({ label, onClick }: { label: string; onClick: () => void }) { return ( ) } function StepBtn({ active, onClick, label, }: { active: boolean onClick: () => void label: string }) { return ( ) } function TabBtn({ active, onClick, label, count, }: { active: boolean onClick: () => void label: string count?: number }) { return ( ) } // ===== Slider ============================================================= function TimelineSlider({ range, marks, value, onChange, currentVersion, }: { range: { min: number; max: number } marks: number[] value: number onChange: (ms: number) => void currentVersion: string }) { const span = range.max - range.min || 1 const pct = ((value - range.min) / span) * 100 const dateLabel = new Date(value).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', }) return (
{/* Version labels (top) — single current marker для текущей schema version. * Когда backend получит snapshots endpoint — здесь будут все версии. */}
v{currentVersion}
{/* Track + cursor (range input для drag UX) */}
{/* Mark ticks */} {marks.map((m) => { const p = ((m - range.min) / span) * 100 if (p < 0 || p > 100) return null return ( ) })} {/* Cursor */} {/* Hidden range input для drag */} onChange(Number(e.target.value))} className="absolute inset-0 opacity-0 cursor-pointer" aria-label="time-travel slider" />
{/* Date labels (bottom) — start / value / now. * Boundary-метки (min/max) скрываются, когда курсор подъехал близко, * иначе текст накладывается в "05.005.05". Порог 12% подобран так, * чтобы две DD.MM метки ~ 36px @ text-cap не пересекались на типичной * ширине трека ~340px. */}
{pct > 12 && ( {new Date(range.min).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', })} )} {dateLabel} {pct < 88 && ( {new Date(range.max).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', })} )}
) } function pluralRu(n: number, one: string, few: string, many: string): string { const mod10 = n % 10 const mod100 = n % 100 if (mod10 === 1 && mod100 !== 11) return one if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few return many } /** * «Что изменилось» tab — список changelog events которые произошли * после picked moment. Каждый event с kind-aware icon/badge + summary * counts + кнопка «Diff» открывает {@link ChangelogDiffModal} с full * before/after JSON. * *

isAtNow={true} → empty state «нет изменений». isLoading → skeleton. * events=[] → «между этими версиями нет изменений». */ function ChangesTabContent({ isAtNow, loading, events, onOpenDiff, }: { isAtNow: boolean loading: boolean events: ChangelogEntry[] onOpenDiff: (id: number) => void }) { const { t } = useTranslation() if (isAtNow) { return (

{t('timeTravel.empty.same', { defaultValue: 'Между этими версиями нет изменений.', })}
) } if (loading) { return (
{[1, 2, 3].map((i) => (
))}
) } if (events.length === 0) { return (
{t('timeTravel.empty.noEvents', { defaultValue: 'Между этими моментами ничего не менялось.', })}
) } return ( ) } /** * «Записи на момент» tab — table of records as-of picked moment. * Uses {@code useRecords(at: pickedISO)} — backend findActiveAt(at). * *

Top 50 rendered. Если больше — показываем counter с pagination * notice (full pagination не нужен в snapshot view). */ function RecordsTabContent({ loading, records, pickedDate, }: { loading: boolean records: FlattenedRecord[] pickedDate: Date }) { const { t } = useTranslation() if (loading) { return (

{[1, 2, 3, 4, 5].map((i) => (
))}
) } if (records.length === 0) { return (
{t('timeTravel.records.empty', { date: pickedDate.toLocaleDateString(), defaultValue: `На ${pickedDate.toLocaleDateString()} записей не было.`, })}
) } const visible = records.slice(0, 50) const truncated = records.length > 50 return (
{t('timeTravel.records.title', { count: records.length, defaultValue: `${records.length} записей на ${pickedDate.toLocaleDateString()}`, })}
{visible.map((r) => { const displayName = (r.data['name_ru'] as string | undefined) ?? (r.data['name'] as string | undefined) ?? (r.data['name_en'] as string | undefined) ?? '—' return ( ) })}
businessKey {t('timeTravel.records.col.name', { defaultValue: 'Название' })} scope {t('timeTravel.records.col.validFrom', { defaultValue: 'Действует с' })}
{r.businessKey} {displayName} {r.dataScope} {new Date(r.validFrom).toLocaleDateString()}
{truncated && (
{t('timeTravel.records.truncated', { visible: visible.length, total: records.length, defaultValue: `Показано ${visible.length} из ${records.length}. Открыть как readonly для полного списка.`, })}
)}
) } /** * «Структура и поля» tab — fields list at picked version + history panel * на правой колонке. * *

Поля fetch'аются через ChangelogDiff endpoint (after schema). Если * pickedEntry null — fallback на current schemaJson. * *

Right panel показывает changelog entry который attached к picked * version: commit (audit id), автор, дата, headers/message. */ function SchemaTabContent({ dictName, currentSchema, currentVersion, pickedEntry, }: { dictName: string currentSchema: { properties?: Record } currentVersion: string pickedEntry: ChangelogEntry | null }) { const { t } = useTranslation() // Fetch full diff для picked entry чтобы получить historical `after` // schema. Если pickedEntry — current/latest, after === current; иначе // berunaround older snapshot. const diff = useChangelogDiff(dictName, pickedEntry?.id) const after = (diff.data?.after ?? null) as | { properties?: Record } | null // Fallback chain: diff.after → currentSchema. Эта семантика: // "если для версии диффа нет → показываем live schema with disclaimer". const propsSource = after ?? currentSchema const fields = Object.keys(propsSource.properties ?? {}).sort() const versionLabel = pickedEntry?.version ?? currentVersion const isCurrentSchemaFallback = !after && Boolean(pickedEntry) return (

{/* LEFT — fields list */}
{t('timeTravel.schema.fieldsTitle', { version: versionLabel, defaultValue: `Поля справочника на v${versionLabel}`, })}
{diff.isLoading && (
{[1, 2, 3, 4, 5].map((i) => (
))}
)} {!diff.isLoading && fields.length === 0 && (
{t('timeTravel.schema.empty', { defaultValue: 'Полей нет.' })}
)} {!diff.isLoading && fields.length > 0 && (
    {fields.map((f) => (
  • {f}
  • ))}
)} {isCurrentSchemaFallback && (
{t('timeTravel.schema.fallback', { defaultValue: 'Показана live-схема — diff на эту версию недоступен.', })}
)}
{/* RIGHT — history panel */}
{t('timeTravel.schema.historyTitle', { defaultValue: 'История этой версии', })}
{pickedEntry ? (
commit
#{pickedEntry.id}
{t('timeTravel.schema.author', { defaultValue: 'автор' })}
{t('timeTravel.schema.date', { defaultValue: 'дата' })}
{new Date(pickedEntry.publishedAt).toLocaleString()}
{pickedEntry.headers && (
«{pickedEntry.headers}»
)}
) : (
{t('timeTravel.schema.noHistory', { defaultValue: 'История недоступна для выбранного момента.', })}
)}
) }