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')}
{t('timeTravel.close')}
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) => (
onPick(p.deltaMs)}
className="text-cap px-2.5 py-1 rounded-full border border-line hover:border-accent hover:bg-accent/4 font-mono text-ink 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-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')}
/>
{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] ?? ''
}