import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react' import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import type { DictionaryDetail } from '@/api/client' 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') // 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={0} /> setTab('records')} label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })} count={totalRecords} /> setTab('schema')} label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })} />
{/* Tab content */}
{tab === 'changes' && ( {isAtNow ? t('timeTravel.empty.same', { defaultValue: 'Между этими версиями нет изменений.', }) : t('timeTravel.placeholder.changes', { defaultValue: 'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.', })} )} {tab === 'records' && ( {pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '} {totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')} )} {tab === 'schema' && ( v{detail.schemaVersion} ·{' '} {Object.keys(detail.schemaJson.properties ?? {}).length}{' '} {pluralRu( Object.keys(detail.schemaJson.properties ?? {}).length, 'поле', 'поля', 'полей', )} )}
{/* 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 && · {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 */}
{new Date(range.min).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', })} {dateLabel} {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 }