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:
*
*
Header: clock icon + TIME-TRAVEL · {DICT} cap + h1 title + ×
*
Compare panels: СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ side-by-side.
* Right panel highlighted (`tint`) когда target != current.
*
Quick presets: сейчас / неделя / месяц / год / при создании
*
Step toggle: версия | день + ‹ › nav arrows
*
Slider: HTML range + overlay version markers (top) + date
* markers (bottom). Cursor — dark circle.
*
Tabs: Что изменилось / Записи на момент / Структура и поля.
* «Что изменилось» — wired через {@code useChangelog} + filter
* events с {@code publishedAt >= picked}. Diff button открывает
* {@link ChangelogDiffModal} с full before/after JSON.
*
Footer: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA
*
*
*
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 (
)
}
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 (
{/* 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. */}
)
}
/**
* «Записи на момент» 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 (
{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 (
{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}`,
})}