Merge branch 'fix/v1.1.2-timeline-svg' into 'main'
fix(admin-ui): TimeTravelPicker → visual SVG timeline See merge request 2-6/2-6-4/terravault/ordinis!25
This commit is contained in:
@@ -1,121 +1,53 @@
|
|||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DatePicker } from '@nstart/ui'
|
|
||||||
import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react'
|
import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picker для time-travel: «активная версия на момент X».
|
* Time-travel picker с visual timeline.
|
||||||
*
|
*
|
||||||
* <p>До v1.1.1 был голый {@code <input type="datetime-local">} — pre-2010
|
* <p>Horizontal SVG-track с tick marks на каждой уникальной дате изменения
|
||||||
* native control с непредсказуемым стилем по браузерам и без TZ awareness.
|
* (validFrom активных версий records этого справочника). Draggable cursor
|
||||||
* Здесь:
|
* показывает текущий {@code at}. Click on track → jump.
|
||||||
* <ul>
|
|
||||||
* <li>{@code DatePicker} из {@code @nstart/ui} (RU locale, beautiful native dropdown).</li>
|
|
||||||
* <li>Time entry — двух-полевой HH:MM (number inputs).</li>
|
|
||||||
* <li>Quick-preset chips: «Сейчас» / «−1ч» / «Вчера» / «Неделя» / «Месяц» / «Год».</li>
|
|
||||||
* <li>Relative-time бейдж рядом со значением (через {@code Intl.RelativeTimeFormat}).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
*
|
||||||
* <p>State модель: контролируемый компонент, parent держит {@code at: ISO string |
|
* <p>Range: от min(marks) (или −1y если меток нет) до now + small padding.
|
||||||
* undefined}. Undefined = «сейчас» (live). Любое preset / picker change → onChange
|
* Year/month labels на оси автомасштабируются по диапазону.
|
||||||
* с новым ISO. Сброс через {@code onClear} (parent очищает {@code ?at} в URL).
|
*
|
||||||
|
* <p>Quick-preset chips для быстрых прыжков (Сейчас / −1ч / Вчера / −7д / −30д
|
||||||
|
* / −1г). Status footer показывает absolute date + relative-time через
|
||||||
|
* {@code Intl.RelativeTimeFormat}.
|
||||||
*/
|
*/
|
||||||
export function TimeTravelPicker({
|
export function TimeTravelPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onClear,
|
onClear,
|
||||||
onClose,
|
onClose,
|
||||||
|
/** Уникальные validFrom timestamps (ms epoch) — рендерятся как tick marks. */
|
||||||
|
marks = [],
|
||||||
}: {
|
}: {
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
onChange: (iso: string) => void
|
onChange: (iso: string) => void
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
marks?: number[]
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const lang = (i18n.language || 'ru').split('-')[0]
|
const lang = (i18n.language || 'ru').split('-')[0]
|
||||||
|
|
||||||
// Текущее значение date-only (для DatePicker) и time HH:MM (для number inputs).
|
|
||||||
const valueDate = useMemo(() => {
|
const valueDate = useMemo(() => {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
const d = new Date(value)
|
const d = new Date(value)
|
||||||
return Number.isNaN(d.getTime()) ? undefined : d
|
return Number.isNaN(d.getTime()) ? undefined : d
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
const hh = valueDate ? valueDate.getHours() : new Date().getHours()
|
// Range: from min(marks, value) − padding до now + padding.
|
||||||
const mm = valueDate ? valueDate.getMinutes() : new Date().getMinutes()
|
const range = useMemo(() => {
|
||||||
|
const now = Date.now()
|
||||||
// Combine date + HH:MM в ISO с локальным TZ-offset (preserve wall-clock).
|
let minMark = marks.length > 0 ? Math.min(...marks) : now - 365 * 86_400_000
|
||||||
const emitWithTime = (date: Date, hours: number, minutes: number) => {
|
if (valueDate) minMark = Math.min(minMark, valueDate.getTime())
|
||||||
const d = new Date(
|
const span = now - minMark
|
||||||
date.getFullYear(),
|
const pad = Math.max(span * 0.05, 86_400_000) // ≥1 день
|
||||||
date.getMonth(),
|
return { min: minMark - pad, max: now + pad }
|
||||||
date.getDate(),
|
}, [marks, valueDate])
|
||||||
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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 rounded-md border border-orbit/40 bg-orbit/4 text-sm">
|
<div className="flex flex-col gap-3 px-4 py-3 rounded-md border border-orbit/40 bg-orbit/4 text-sm">
|
||||||
@@ -134,40 +66,279 @@ export function TimeTravelPicker({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<TimelineTrack
|
||||||
<DatePicker
|
range={range}
|
||||||
label={t('timeTravel.date')}
|
marks={marks}
|
||||||
value={valueDate}
|
value={valueDate}
|
||||||
onChange={handleDateChange}
|
onChange={(d) => onChange(d.toISOString())}
|
||||||
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-2xs uppercase tracking-label text-carbon/70 font-secondary">
|
|
||||||
{t('timeTravel.time')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={23}
|
|
||||||
value={String(hh).padStart(2, '0')}
|
|
||||||
onChange={(e) => 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')}
|
|
||||||
/>
|
|
||||||
<span className="text-carbon/50 font-mono">:</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={59}
|
|
||||||
value={String(mm).padStart(2, '0')}
|
|
||||||
onChange={(e) => 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')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<QuickPresets onPick={(deltaMs) => onChange(new Date(Date.now() + deltaMs).toISOString())} />
|
||||||
|
|
||||||
|
<StatusFooter
|
||||||
|
valueDate={valueDate}
|
||||||
|
lang={lang}
|
||||||
|
onClear={onClear}
|
||||||
|
onChangeTime={(hours, minutes) => {
|
||||||
|
const base = valueDate ?? new Date()
|
||||||
|
const d = new Date(
|
||||||
|
base.getFullYear(),
|
||||||
|
base.getMonth(),
|
||||||
|
base.getDate(),
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
onChange(d.toISOString())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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<HTMLDivElement>(null)
|
||||||
|
const [width, setWidth] = useState(800)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [hover, setHover] = useState<number | null>(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<SVGSVGElement>) => {
|
||||||
|
;(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<SVGSVGElement>) => {
|
||||||
|
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<SVGSVGElement>) => {
|
||||||
|
;(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 (
|
||||||
|
<div ref={wrapperRef} className="relative w-full select-none">
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={TRACK_HEIGHT}
|
||||||
|
viewBox={`0 0 ${width} ${TRACK_HEIGHT}`}
|
||||||
|
className="block touch-none cursor-pointer"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
>
|
||||||
|
{/* Track baseline */}
|
||||||
|
<line
|
||||||
|
x1={TRACK_PAD_X}
|
||||||
|
x2={width - TRACK_PAD_X}
|
||||||
|
y1={TRACK_HEIGHT / 2}
|
||||||
|
y2={TRACK_HEIGHT / 2}
|
||||||
|
stroke="#d4d4d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Past portion highlighted (от min до cursor) */}
|
||||||
|
{hasValue && (
|
||||||
|
<line
|
||||||
|
x1={TRACK_PAD_X}
|
||||||
|
x2={cursorX}
|
||||||
|
y1={TRACK_HEIGHT / 2}
|
||||||
|
y2={TRACK_HEIGHT / 2}
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Now indicator (right edge) */}
|
||||||
|
<g>
|
||||||
|
<line
|
||||||
|
x1={nowX}
|
||||||
|
x2={nowX}
|
||||||
|
y1={TRACK_HEIGHT / 2 - 8}
|
||||||
|
y2={TRACK_HEIGHT / 2 + 8}
|
||||||
|
stroke="#16a34a"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={nowX}
|
||||||
|
y={TRACK_HEIGHT / 2 - 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="ui-monospace,monospace"
|
||||||
|
fill="#16a34a"
|
||||||
|
>
|
||||||
|
now
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Year axis ticks (выше baseline) */}
|
||||||
|
{yearTicks.map((tick) => {
|
||||||
|
const x = timeToX(tick.t)
|
||||||
|
return (
|
||||||
|
<g key={tick.t}>
|
||||||
|
<line
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={TRACK_HEIGHT / 2 + 4}
|
||||||
|
y2={TRACK_HEIGHT / 2 + 8}
|
||||||
|
stroke="#a1a1aa"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={TRACK_HEIGHT - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="ui-monospace,monospace"
|
||||||
|
fill="#71717a"
|
||||||
|
>
|
||||||
|
{tick.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Record marks (validFrom timestamps) — vertical ticks above track */}
|
||||||
|
{marks.map((m) => {
|
||||||
|
const x = timeToX(m)
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={m}
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={TRACK_HEIGHT / 2 - 10}
|
||||||
|
y2={TRACK_HEIGHT / 2 - 2}
|
||||||
|
stroke="#7c3aed"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Cursor (draggable) */}
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
cx={cursorX}
|
||||||
|
cy={TRACK_HEIGHT / 2}
|
||||||
|
r={9}
|
||||||
|
fill={hasValue ? '#3b82f6' : '#94a3b8'}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={cursorX}
|
||||||
|
cy={TRACK_HEIGHT / 2}
|
||||||
|
r={3}
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltipText && hover !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute -top-2 -translate-y-full px-2 py-0.5 rounded bg-carbon text-white text-2xs font-mono pointer-events-none whitespace-nowrap"
|
||||||
|
style={{ left: timeToX(hover), transform: 'translate(-50%, -100%)' }}
|
||||||
|
>
|
||||||
|
{tooltipText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{marks.length > 0 && (
|
||||||
|
<div className="text-2xs text-carbon/50 font-secondary mt-1">
|
||||||
|
{marks.length === 1
|
||||||
|
? '1 точка изменений'
|
||||||
|
: marks.length < 5
|
||||||
|
? `${marks.length} точки изменений`
|
||||||
|
: `${marks.length} точек изменений`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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 (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-2xs uppercase tracking-label text-carbon/60 font-secondary mr-1">
|
<span className="text-2xs uppercase tracking-label text-carbon/60 font-secondary mr-1">
|
||||||
{t('timeTravel.quickPresets')}
|
{t('timeTravel.quickPresets')}
|
||||||
@@ -176,16 +347,53 @@ export function TimeTravelPicker({
|
|||||||
<button
|
<button
|
||||||
key={p.key}
|
key={p.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyPreset(p.deltaMs)}
|
onClick={() => 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"
|
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}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{valueDate && (
|
// ===== Status footer + time tweaker =======================================
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-orbit/20">
|
|
||||||
|
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 (
|
||||||
|
<div className="text-2xs text-carbon/60 font-secondary italic">
|
||||||
|
{t('timeTravel.hint')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-orbit/20">
|
||||||
<ClockCounterClockwiseIcon weight="regular" size={14} className="text-orbit shrink-0" />
|
<ClockCounterClockwiseIcon weight="regular" size={14} className="text-orbit shrink-0" />
|
||||||
<span className="text-xs font-mono text-carbon/80">
|
<span className="text-xs font-mono text-carbon/80">
|
||||||
{valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
|
{valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||||
@@ -198,6 +406,37 @@ export function TimeTravelPicker({
|
|||||||
({relativeLabel})
|
({relativeLabel})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={String(valueDate.getHours()).padStart(2, '0')}
|
||||||
|
onChange={(e) =>
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
<span className="text-carbon/50 font-mono text-2xs">:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
value={String(valueDate.getMinutes()).padStart(2, '0')}
|
||||||
|
onChange={(e) =>
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-auto text-2xs text-ultramarain hover:underline"
|
className="ml-auto text-2xs text-ultramarain hover:underline"
|
||||||
@@ -206,7 +445,98 @@ export function TimeTravelPicker({
|
|||||||
{t('timeTravel.clear')}
|
{t('timeTravel.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 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] ?? ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ i18n
|
|||||||
'timeTravel.preset.weekAgo': '−7 дней',
|
'timeTravel.preset.weekAgo': '−7 дней',
|
||||||
'timeTravel.preset.monthAgo': '−30 дней',
|
'timeTravel.preset.monthAgo': '−30 дней',
|
||||||
'timeTravel.preset.yearAgo': '−1 год',
|
'timeTravel.preset.yearAgo': '−1 год',
|
||||||
|
'timeTravel.hint': 'Кликните или потяните точку на оси чтобы выбрать момент.',
|
||||||
// === Cascade close (Phase 3) ===
|
// === Cascade close (Phase 3) ===
|
||||||
'cascade.title': 'Каскадное закрытие',
|
'cascade.title': 'Каскадное закрытие',
|
||||||
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
||||||
@@ -876,6 +877,7 @@ i18n
|
|||||||
'timeTravel.preset.weekAgo': '−7 days',
|
'timeTravel.preset.weekAgo': '−7 days',
|
||||||
'timeTravel.preset.monthAgo': '−30 days',
|
'timeTravel.preset.monthAgo': '−30 days',
|
||||||
'timeTravel.preset.yearAgo': '−1 year',
|
'timeTravel.preset.yearAgo': '−1 year',
|
||||||
|
'timeTravel.hint': 'Click or drag the dot on the axis to pick a moment.',
|
||||||
// === Cascade close (Phase 3) ===
|
// === Cascade close (Phase 3) ===
|
||||||
'cascade.title': 'Cascade close',
|
'cascade.title': 'Cascade close',
|
||||||
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
||||||
|
|||||||
@@ -227,6 +227,18 @@ function DictionaryDetail() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Уникальные validFrom timestamps для timeline marks. Используем raw
|
||||||
|
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
|
||||||
|
// фильтров.
|
||||||
|
const recordTimestamps = useMemo(() => {
|
||||||
|
const set = new Set<number>()
|
||||||
|
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 filteredRecords = useMemo(() => {
|
||||||
const raw = recordsResult.data ?? []
|
const raw = recordsResult.data ?? []
|
||||||
if (raw.length === 0) return raw
|
if (raw.length === 0) return raw
|
||||||
@@ -624,6 +636,7 @@ function DictionaryDetail() {
|
|||||||
onChange={(iso) => setTimeTravelAt(iso)}
|
onChange={(iso) => setTimeTravelAt(iso)}
|
||||||
onClear={() => setTimeTravelAt(undefined)}
|
onClear={() => setTimeTravelAt(undefined)}
|
||||||
onClose={() => setTimeTravelOpen(false)}
|
onClose={() => setTimeTravelOpen(false)}
|
||||||
|
marks={recordTimestamps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user