diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx
index f5036cd..5e3f5ca 100644
--- a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx
+++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx
@@ -1,121 +1,53 @@
-import { useMemo } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { DatePicker } from '@nstart/ui'
import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react'
/**
- * Picker для time-travel: «активная версия на момент X».
+ * Time-travel picker с visual timeline.
*
- *
До v1.1.1 был голый {@code } — pre-2010
- * native control с непредсказуемым стилем по браузерам и без TZ awareness.
- * Здесь:
- *
- * {@code DatePicker} из {@code @nstart/ui} (RU locale, beautiful native dropdown).
- * Time entry — двух-полевой HH:MM (number inputs).
- * Quick-preset chips: «Сейчас» / «−1ч» / «Вчера» / «Неделя» / «Месяц» / «Год».
- * Relative-time бейдж рядом со значением (через {@code Intl.RelativeTimeFormat}).
- *
+ * Horizontal SVG-track с tick marks на каждой уникальной дате изменения
+ * (validFrom активных версий records этого справочника). Draggable cursor
+ * показывает текущий {@code at}. Click on track → jump.
*
- *
State модель: контролируемый компонент, parent держит {@code at: ISO string |
- * undefined}. Undefined = «сейчас» (live). Любое preset / picker change → onChange
- * с новым ISO. Сброс через {@code onClear} (parent очищает {@code ?at} в URL).
+ *
Range: от min(marks) (или −1y если меток нет) до now + small padding.
+ * Year/month labels на оси автомасштабируются по диапазону.
+ *
+ *
Quick-preset chips для быстрых прыжков (Сейчас / −1ч / Вчера / −7д / −30д
+ * / −1г). Status footer показывает absolute date + relative-time через
+ * {@code Intl.RelativeTimeFormat}.
*/
export function TimeTravelPicker({
value,
onChange,
onClear,
onClose,
+ /** Уникальные validFrom timestamps (ms epoch) — рендерятся как tick marks. */
+ marks = [],
}: {
value: string | undefined
onChange: (iso: string) => void
onClear: () => void
onClose: () => void
+ marks?: number[]
}) {
const { t, i18n } = useTranslation()
const lang = (i18n.language || 'ru').split('-')[0]
- // Текущее значение date-only (для DatePicker) и time HH:MM (для number inputs).
const valueDate = useMemo(() => {
if (!value) return undefined
const d = new Date(value)
return Number.isNaN(d.getTime()) ? undefined : d
}, [value])
- const hh = valueDate ? valueDate.getHours() : new Date().getHours()
- const mm = valueDate ? valueDate.getMinutes() : new Date().getMinutes()
-
- // Combine date + HH:MM в ISO с локальным TZ-offset (preserve wall-clock).
- const emitWithTime = (date: Date, hours: number, minutes: number) => {
- const d = new Date(
- date.getFullYear(),
- date.getMonth(),
- date.getDate(),
- hours,
- minutes,
- 0,
- 0,
- )
- onChange(d.toISOString())
- }
-
- const handleDateChange = (d: Date | null) => {
- if (!d) {
- onClear()
- return
- }
- emitWithTime(d, hh, mm)
- }
-
- const handleHoursChange = (h: number) => {
- if (!valueDate) {
- // First time-edit без выбранной даты → принимаем «сегодня».
- emitWithTime(new Date(), Math.max(0, Math.min(23, h)), mm)
- return
- }
- emitWithTime(valueDate, Math.max(0, Math.min(23, h)), mm)
- }
-
- const handleMinutesChange = (m: number) => {
- if (!valueDate) {
- emitWithTime(new Date(), hh, Math.max(0, Math.min(59, m)))
- return
- }
- emitWithTime(valueDate, hh, Math.max(0, Math.min(59, m)))
- }
-
- const applyPreset = (deltaMs: number) => {
- const target = new Date(Date.now() + deltaMs)
- onChange(target.toISOString())
- }
-
- // Relative-time строка: «3 ч назад», «через 2 дня». Intl.RelativeTimeFormat
- // нативный, без depends, locale-aware.
- const relativeLabel = useMemo(() => {
- if (!valueDate) return null
- const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' })
- const diffMs = valueDate.getTime() - Date.now()
- const absH = Math.abs(diffMs) / 3_600_000
- const absD = absH / 24
- if (absH < 1) {
- return rtf.format(Math.round(diffMs / 60_000), 'minute')
- }
- if (absH < 48) {
- return rtf.format(Math.round(diffMs / 3_600_000), 'hour')
- }
- if (absD < 60) {
- return rtf.format(Math.round(diffMs / 86_400_000), 'day')
- }
- return rtf.format(Math.round(diffMs / (86_400_000 * 30)), 'month')
- }, [valueDate, lang])
-
- const presets: ReadonlyArray<{ key: string; label: string; deltaMs: number }> = [
- { key: 'now', label: t('timeTravel.preset.now'), deltaMs: 0 },
- { key: '-1h', label: t('timeTravel.preset.hourAgo'), deltaMs: -3_600_000 },
- { key: '-1d', label: t('timeTravel.preset.dayAgo'), deltaMs: -86_400_000 },
- { key: '-7d', label: t('timeTravel.preset.weekAgo'), deltaMs: -7 * 86_400_000 },
- { key: '-30d', label: t('timeTravel.preset.monthAgo'), deltaMs: -30 * 86_400_000 },
- { key: '-1y', label: t('timeTravel.preset.yearAgo'), deltaMs: -365 * 86_400_000 },
- ]
+ // Range: from min(marks, value) − padding до now + padding.
+ const range = useMemo(() => {
+ const now = Date.now()
+ let minMark = marks.length > 0 ? Math.min(...marks) : now - 365 * 86_400_000
+ if (valueDate) minMark = Math.min(minMark, valueDate.getTime())
+ const span = now - minMark
+ const pad = Math.max(span * 0.05, 86_400_000) // ≥1 день
+ return { min: minMark - pad, max: now + pad }
+ }, [marks, valueDate])
return (
@@ -134,79 +66,477 @@ export function TimeTravelPicker({
-
- onChange(d.toISOString())}
+ lang={lang}
+ />
+
+ onChange(new Date(Date.now() + deltaMs).toISOString())} />
+
+ {
+ const base = valueDate ?? new Date()
+ const d = new Date(
+ base.getFullYear(),
+ base.getMonth(),
+ base.getDate(),
+ hours,
+ minutes,
+ 0,
+ 0,
+ )
+ onChange(d.toISOString())
+ }}
+ />
+
+ )
+}
+
+// ===== Timeline track =====================================================
+
+const TRACK_HEIGHT = 56
+const TRACK_PAD_X = 12
+
+function TimelineTrack({
+ range,
+ marks,
+ value,
+ onChange,
+ lang,
+}: {
+ range: { min: number; max: number }
+ marks: number[]
+ value: Date | undefined
+ onChange: (d: Date) => void
+ lang: string
+}) {
+ const wrapperRef = useRef(null)
+ const [width, setWidth] = useState(800)
+ const [dragging, setDragging] = useState(false)
+ const [hover, setHover] = useState(null)
+
+ // Resize observer для responsive ширины track'а.
+ useEffect(() => {
+ const el = wrapperRef.current
+ if (!el) return
+ const ro = new ResizeObserver(() => {
+ setWidth(el.clientWidth)
+ })
+ ro.observe(el)
+ setWidth(el.clientWidth)
+ return () => ro.disconnect()
+ }, [])
+
+ const innerW = Math.max(width - TRACK_PAD_X * 2, 100)
+ const span = range.max - range.min || 1
+
+ const timeToX = (t: number) => TRACK_PAD_X + ((t - range.min) / span) * innerW
+ const xToTime = (x: number) => {
+ const clamped = Math.max(TRACK_PAD_X, Math.min(width - TRACK_PAD_X, x))
+ return range.min + ((clamped - TRACK_PAD_X) / innerW) * span
+ }
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ ;(e.target as Element).setPointerCapture?.(e.pointerId)
+ setDragging(true)
+ const rect = e.currentTarget.getBoundingClientRect()
+ onChange(new Date(xToTime(e.clientX - rect.left)))
+ }
+ const handlePointerMove = (e: React.PointerEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const t = xToTime(e.clientX - rect.left)
+ setHover(t)
+ if (dragging) onChange(new Date(t))
+ }
+ const handlePointerUp = (e: React.PointerEvent) => {
+ ;(e.target as Element).releasePointerCapture?.(e.pointerId)
+ setDragging(false)
+ }
+ const handlePointerLeave = () => {
+ setHover(null)
+ }
+
+ // Year/month tick labels — выбираем интервал в зависимости от span.
+ const yearTicks = useMemo(() => buildAxisTicks(range.min, range.max), [range.min, range.max])
+
+ const cursorX = value ? timeToX(value.getTime()) : timeToX(Date.now())
+ const hasValue = Boolean(value)
+ const nowX = timeToX(Date.now())
+
+ // Tooltip для hover над треком (ещё не drop'нул) — без перетаскивания.
+ const tooltipText = useMemo(() => {
+ if (hover === null) return null
+ return new Date(hover).toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })
+ }, [hover, lang])
+
+ return (
+
+
+ {/* Track baseline */}
+
-
-
- {t('timeTravel.time')}
-
-
- handleHoursChange(parseInt(e.target.value, 10) || 0)}
- className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center"
- aria-label={t('timeTravel.hours')}
+
+ {/* Past portion highlighted (от min до cursor) */}
+ {hasValue && (
+
+ )}
+
+ {/* Now indicator (right edge) */}
+
+
+
+ now
+
+
+
+ {/* Year axis ticks (выше baseline) */}
+ {yearTicks.map((tick) => {
+ const x = timeToX(tick.t)
+ return (
+
+
+
+ {tick.label}
+
+
+ )
+ })}
+
+ {/* Record marks (validFrom timestamps) — vertical ticks above track */}
+ {marks.map((m) => {
+ const x = timeToX(m)
+ return (
+
- :
- handleMinutesChange(parseInt(e.target.value, 10) || 0)}
- className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center"
- aria-label={t('timeTravel.minutes')}
- />
-
+ )
+ })}
+
+ {/* Cursor (draggable) */}
+
+
+
+
+
+
+ {tooltipText && hover !== null && (
+
+ {tooltipText}
-
+ )}
-
-
- {t('timeTravel.quickPresets')}
-
- {presets.map((p) => (
- applyPreset(p.deltaMs)}
- className="px-2.5 py-1 rounded-full border border-regolith hover:border-ultramarain hover:bg-ultramarain/4 text-2xs font-mono uppercase tracking-label text-carbon transition-colors"
- >
- {p.label}
-
- ))}
-
-
- {valueDate && (
-
-
-
- {valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
- dateStyle: 'long',
- timeStyle: 'short',
- })}
-
- {relativeLabel && (
-
- ({relativeLabel})
-
- )}
-
- {t('timeTravel.clear')}
-
+ {marks.length > 0 && (
+
+ {marks.length === 1
+ ? '1 точка изменений'
+ : marks.length < 5
+ ? `${marks.length} точки изменений`
+ : `${marks.length} точек изменений`}
)}
)
}
+
+// ===== Quick preset chips =================================================
+
+function QuickPresets({ onPick }: { onPick: (deltaMs: number) => void }) {
+ const { t } = useTranslation()
+ const presets: ReadonlyArray<{ key: string; label: string; deltaMs: number }> = [
+ { key: 'now', label: t('timeTravel.preset.now'), deltaMs: 0 },
+ { key: '-1h', label: t('timeTravel.preset.hourAgo'), deltaMs: -3_600_000 },
+ { key: '-1d', label: t('timeTravel.preset.dayAgo'), deltaMs: -86_400_000 },
+ { key: '-7d', label: t('timeTravel.preset.weekAgo'), deltaMs: -7 * 86_400_000 },
+ { key: '-30d', label: t('timeTravel.preset.monthAgo'), deltaMs: -30 * 86_400_000 },
+ { key: '-1y', label: t('timeTravel.preset.yearAgo'), deltaMs: -365 * 86_400_000 },
+ ]
+ return (
+
+
+ {t('timeTravel.quickPresets')}
+
+ {presets.map((p) => (
+ onPick(p.deltaMs)}
+ className="px-2.5 py-1 rounded-full border border-regolith hover:border-ultramarain hover:bg-ultramarain/4 text-2xs font-mono uppercase tracking-label text-carbon transition-colors"
+ >
+ {p.label}
+
+ ))}
+
+ )
+}
+
+// ===== Status footer + time tweaker =======================================
+
+function StatusFooter({
+ valueDate,
+ lang,
+ onClear,
+ onChangeTime,
+}: {
+ valueDate: Date | undefined
+ lang: string
+ onClear: () => void
+ onChangeTime: (hours: number, minutes: number) => void
+}) {
+ const { t } = useTranslation()
+
+ const relativeLabel = useMemo(() => {
+ if (!valueDate) return null
+ const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' })
+ const diffMs = valueDate.getTime() - Date.now()
+ const absH = Math.abs(diffMs) / 3_600_000
+ const absD = absH / 24
+ if (absH < 1) return rtf.format(Math.round(diffMs / 60_000), 'minute')
+ if (absH < 48) return rtf.format(Math.round(diffMs / 3_600_000), 'hour')
+ if (absD < 60) return rtf.format(Math.round(diffMs / 86_400_000), 'day')
+ return rtf.format(Math.round(diffMs / (86_400_000 * 30)), 'month')
+ }, [valueDate, lang])
+
+ if (!valueDate) {
+ return (
+
+ {t('timeTravel.hint')}
+
+ )
+ }
+
+ return (
+
+
+
+ {valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
+ dateStyle: 'long',
+ timeStyle: 'short',
+ })}
+
+ {relativeLabel && (
+
+ ({relativeLabel})
+
+ )}
+
+
+ onChangeTime(
+ Math.max(0, Math.min(23, parseInt(e.target.value, 10) || 0)),
+ valueDate.getMinutes(),
+ )
+ }
+ className="w-10 px-1.5 py-0.5 rounded-sm border border-regolith bg-white text-2xs font-mono text-center"
+ aria-label={t('timeTravel.hours')}
+ />
+ :
+
+ onChangeTime(
+ valueDate.getHours(),
+ Math.max(0, Math.min(59, parseInt(e.target.value, 10) || 0)),
+ )
+ }
+ className="w-10 px-1.5 py-0.5 rounded-sm border border-regolith bg-white text-2xs font-mono text-center"
+ aria-label={t('timeTravel.minutes')}
+ />
+
+
+ {t('timeTravel.clear')}
+
+
+ )
+}
+
+// ===== Axis tick computation ==============================================
+
+/**
+ * Auto-pick tick density по диапазону:
+ * - >5 лет → tick на каждый год
+ * - 1-5 лет → tick на каждый квартал
+ * - 1-12 месяцев → tick на каждый месяц
+ * - <1 месяца → tick на неделю/несколько дней
+ */
+function buildAxisTicks(min: number, max: number): { t: number; label: string }[] {
+ const span = max - min
+ const yearMs = 365 * 86_400_000
+ const out: { t: number; label: string }[] = []
+
+ if (span > 5 * yearMs) {
+ // Yearly
+ const start = new Date(min)
+ start.setMonth(0, 1)
+ start.setHours(0, 0, 0, 0)
+ let y = start.getFullYear()
+ while (true) {
+ const t = new Date(y, 0, 1).getTime()
+ if (t > max) break
+ if (t >= min) out.push({ t, label: String(y) })
+ y++
+ }
+ } else if (span > yearMs) {
+ // Quarterly
+ const start = new Date(min)
+ start.setDate(1)
+ start.setHours(0, 0, 0, 0)
+ let y = start.getFullYear()
+ let m = Math.floor(start.getMonth() / 3) * 3
+ while (true) {
+ const d = new Date(y, m, 1)
+ if (d.getTime() > max) break
+ if (d.getTime() >= min) {
+ out.push({ t: d.getTime(), label: `${quarterLabel(m)} ${String(y).slice(2)}` })
+ }
+ m += 3
+ if (m >= 12) {
+ m = 0
+ y++
+ }
+ }
+ } else if (span > 30 * 86_400_000) {
+ // Monthly
+ const start = new Date(min)
+ start.setDate(1)
+ start.setHours(0, 0, 0, 0)
+ let y = start.getFullYear()
+ let m = start.getMonth()
+ while (true) {
+ const d = new Date(y, m, 1)
+ if (d.getTime() > max) break
+ if (d.getTime() >= min) {
+ out.push({ t: d.getTime(), label: monthShort(m) })
+ }
+ m++
+ if (m >= 12) {
+ m = 0
+ y++
+ }
+ }
+ } else {
+ // Weekly (~7d)
+ const dayMs = 86_400_000
+ const start = new Date(min)
+ start.setHours(0, 0, 0, 0)
+ let t = start.getTime()
+ while (t <= max) {
+ if (t >= min) {
+ const d = new Date(t)
+ out.push({ t, label: `${d.getDate()} ${monthShort(d.getMonth())}` })
+ }
+ t += 7 * dayMs
+ }
+ }
+ // Limit overcrowding
+ if (out.length > 14) {
+ const stride = Math.ceil(out.length / 12)
+ return out.filter((_, i) => i % stride === 0)
+ }
+ return out
+}
+
+function quarterLabel(month: number): string {
+ return `Q${Math.floor(month / 3) + 1}`
+}
+function monthShort(m: number): string {
+ return ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'][m] ?? ''
+}
diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts
index 9b1b3a5..439be6a 100644
--- a/ordinis-admin-ui/src/i18n.ts
+++ b/ordinis-admin-ui/src/i18n.ts
@@ -401,6 +401,7 @@ i18n
'timeTravel.preset.weekAgo': '−7 дней',
'timeTravel.preset.monthAgo': '−30 дней',
'timeTravel.preset.yearAgo': '−1 год',
+ 'timeTravel.hint': 'Кликните или потяните точку на оси чтобы выбрать момент.',
// === Cascade close (Phase 3) ===
'cascade.title': 'Каскадное закрытие',
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
@@ -876,6 +877,7 @@ i18n
'timeTravel.preset.weekAgo': '−7 days',
'timeTravel.preset.monthAgo': '−30 days',
'timeTravel.preset.yearAgo': '−1 year',
+ 'timeTravel.hint': 'Click or drag the dot on the axis to pick a moment.',
// === Cascade close (Phase 3) ===
'cascade.title': 'Cascade close',
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
index 00aa2d0..939a659 100644
--- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
+++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
@@ -227,6 +227,18 @@ function DictionaryDetail() {
})
}
+ // Уникальные validFrom timestamps для timeline marks. Используем raw
+ // recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
+ // фильтров.
+ const recordTimestamps = useMemo(() => {
+ const set = new Set()
+ for (const r of recordsResult.data ?? []) {
+ const ts = new Date(r.validFrom).getTime()
+ if (!Number.isNaN(ts)) set.add(ts)
+ }
+ return Array.from(set).sort((a, b) => a - b)
+ }, [recordsResult.data])
+
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
if (raw.length === 0) return raw
@@ -624,6 +636,7 @@ function DictionaryDetail() {
onChange={(iso) => setTimeTravelAt(iso)}
onClear={() => setTimeTravelAt(undefined)}
onClose={() => setTimeTravelOpen(false)}
+ marks={recordTimestamps}
/>
)}