import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react' /** * Time-travel picker с visual timeline. * *

Horizontal SVG-track с tick marks на каждой уникальной дате изменения * (validFrom активных версий records этого справочника). Draggable cursor * показывает текущий {@code at}. Click on track → jump. * *

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] const valueDate = useMemo(() => { if (!value) return undefined const d = new Date(value) return Number.isNaN(d.getTime()) ? undefined : d }, [value]) // 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 (

{t('timeTravel.label')}
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 */} {/* 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 ( ) })} {/* Cursor (draggable) */} {tooltipText && hover !== null && (
{tooltipText}
)} {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) => ( ))}
) } // ===== 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-line 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-line bg-white text-2xs font-mono text-center" aria-label={t('timeTravel.minutes')} />
) } // ===== 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] ?? '' }