From 94da10f4870fa648ca31103ef701f8e70652ac31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Sun, 10 May 2026 19:50:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(admin-ui):=20TimeTravelPicker=20=E2=86=92?= =?UTF-8?q?=20visual=20SVG=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timetravel/TimeTravelPicker.tsx | 646 +++++++++++++----- ordinis-admin-ui/src/i18n.ts | 2 + .../src/routes/dictionaries.$name.tsx | 13 + 3 files changed, 503 insertions(+), 158 deletions(-) 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. - * Здесь: - *

+ *

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) => ( - - ))} -
- - {valueDate && ( -
- - - {valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', { - dateStyle: 'long', - timeStyle: 'short', - })} - - {relativeLabel && ( - - ({relativeLabel}) - - )} - + {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-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')} + /> + + +
+ ) +} + +// ===== 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} /> )}