Files
mdm-ordinis/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx
T
Zimin A.N. 71432e9ae7 fix(fonts): align all text sizes to handoff spec via Tailwind theme overrides
Глобальные правки через @theme в styles.css вместо 200+ точечных правок:

- text-sm: 14px -> 13px (handoff workhorse — body, buttons, tabs)
- text-2xs: undefined -> 11px (handoff IDs, mono meta — было невидимо)
- @utility text-cap: 10.5px Tektur uppercase 0.10em (handoff caps)

Codemod (30 sites): text-2xs + uppercase + tracking-[0.10em] -> text-cap.
Sidebar section labels consolidated с explicit text-[10.5px] на text-cap.

Покрывает 64 text-sm + 148 text-2xs автоматически. Table cells (12.5px)
и page/modal titles (17px/22px) остаются explicit text-[Npx] arbitrary
values по месту — нет slots в Tailwind scale.
2026-05-11 14:12:57 +03:00

543 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react'
/**
* Time-travel picker с visual timeline.
*
* <p>Horizontal SVG-track с tick marks на каждой уникальной дате изменения
* (validFrom активных версий records этого справочника). Draggable cursor
* показывает текущий {@code at}. Click on track → jump.
*
* <p>Range: от min(marks) (или −1y если меток нет) до now + small padding.
* Year/month labels на оси автомасштабируются по диапазону.
*
* <p>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 (
<div className="flex flex-col gap-3 px-4 py-3 rounded-md border border-warn bg-warn-bg text-sm">
<div className="flex items-center gap-2">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn" />
<span className="text-cap font-display text-ink-2">
{t('timeTravel.label')}
</span>
<button
type="button"
className="ml-auto text-2xs text-mute hover:text-ink hover:underline inline-flex items-center gap-1"
onClick={onClose}
>
<XIcon weight="bold" size={12} />
{t('timeTravel.close')}
</button>
</div>
<TimelineTrack
range={range}
marks={marks}
value={valueDate}
onChange={(d) => onChange(d.toISOString())}
lang={lang}
/>
<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-mute font-display 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">
<span className="text-cap text-mute font-display mr-1">
{t('timeTravel.quickPresets')}
</span>
{presets.map((p) => (
<button
key={p.key}
type="button"
onClick={() => 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}
</button>
))}
</div>
)
}
// ===== 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 (
<div className="text-2xs text-mute font-display italic">
{t('timeTravel.hint')}
</div>
)
}
return (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-warn/30">
<ClockCounterClockwiseIcon weight="regular" size={14} className="text-warn shrink-0" />
<span className="text-xs font-mono text-ink">
{valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
dateStyle: 'long',
timeStyle: 'short',
})}
</span>
{relativeLabel && (
<span className="text-2xs text-mute font-display lowercase">
({relativeLabel})
</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-line bg-white text-2xs font-mono text-center"
aria-label={t('timeTravel.hours')}
/>
<span className="text-mute 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-line bg-white text-2xs font-mono text-center"
aria-label={t('timeTravel.minutes')}
/>
</span>
<button
type="button"
className="ml-auto text-2xs text-accent hover:underline"
onClick={onClear}
>
{t('timeTravel.clear')}
</button>
</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] ?? ''
}