fix(timetravel): drop arbitrary +30d max — use real marks only

User feedback: «крайняя дата должна быть исходя из реальной в этом
словаре». Раньше slider всегда extended до now + 30d даже без причины —
если нет scheduled записей на 18.05, slider всё равно тянулся туда,
пользователь блуждал по пустоте.

Теперь max = max(marks) — last real change point (record validFrom включая
upcoming scheduled, которые backend возвращает в /scheduled-summary).
Если все marks в прошлом — max=now (always reachable). Если есть future
scheduled на 18.05 — slider тянется ровно туда, не дальше.

Touches: TimeTravelPicker.tsx (inline editor) + TimeTravelModal.tsx (modal).
This commit is contained in:
Zimin A.N.
2026-05-15 21:30:08 +03:00
parent 9199f380e1
commit d0ef822b9e
2 changed files with 20 additions and 24 deletions
@@ -109,25 +109,22 @@ export function TimeTravelModal({
isAtNow0 ? undefined : { at: new Date(picked).toISOString() }, isAtNow0 ? undefined : { at: new Date(picked).toISOString() },
) )
// Range: от min(marks, createdAt) до max(now+30d, future marks)+padding. // Range: derived purely from real data — marks (record validFroms,
// включая upcoming scheduled, передаются parent'ом), createdAt, now.
// No arbitrary future window: max берётся max(marks) — last real change
// point. User feedback: «крайняя дата должна быть исходя из реальной в
// этом словаре».
// //
// Future support (fix paired с TimeTravelPicker !220): если у dict есть // Если все marks в прошлом — max=now (user всё равно может перейти к now).
// scheduled records (validFrom > now) или user navigated к future date, // Если есть scheduled validFrom > now — max тянется до furthest scheduled.
// slider должен дотягиваться. Раньше max = now + 1day pad — record с
// validFrom=18.05 был недостижим если сейчас 15.05.
//
// Default future window = 30 days, match'ит TimeTravelPicker.tsx logic.
const createdMs = Date.parse(detail.createdAt) const createdMs = Date.parse(detail.createdAt)
const range = useMemo(() => { const range = useMemo(() => {
const DAY_MS = 86_400_000 const DAY_MS = 86_400_000
const FUTURE_WINDOW_MS = 30 * DAY_MS
const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - DAY_MS const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - DAY_MS
// Push max forward: at least now + 30 days, и further если есть future marks.
const maxMark = marks.length > 0 ? Math.max(...marks, now) : now const maxMark = marks.length > 0 ? Math.max(...marks, now) : now
const effectiveMax = Math.max(maxMark, now + FUTURE_WINDOW_MS) const span = maxMark - minMark
const span = effectiveMax - minMark
const pad = Math.max(span * 0.04, DAY_MS) const pad = Math.max(span * 0.04, DAY_MS)
return { min: minMark - pad, max: effectiveMax + pad } return { min: minMark - pad, max: maxMark + pad }
}, [marks, createdMs, now]) }, [marks, createdMs, now])
// Sync picked when value prop changes while modal open. // Sync picked when value prop changes while modal open.
@@ -39,20 +39,18 @@ export function TimeTravelPicker({
return Number.isNaN(d.getTime()) ? undefined : d return Number.isNaN(d.getTime()) ? undefined : d
}, [value]) }, [value])
// Range: from min(marks, value) padding до max(now, future marks) + padding. // Range: derived purely from real data — marks (record validFrom timestamps
// включая upcoming scheduled), createdAt, и picked value. Никаких arbitrary
// future windows: если в словаре нет записей на 18.05 — slider туда не
// тянется. User feedback: «крайняя дата должна быть исходя из реальной в
// этом словаре».
// //
// Future support: если у dict'а есть records с validFrom > now (scheduled), // Каноничный max берётся max(marks) — это последняя «интересная точка»
// или user navigated к future date (asOf=18.05 для preview new record), // (либо last existing record validFrom, либо furthest scheduled validFrom).
// slider должен дотягиваться до этой даты. Раньше max = now + 1day pad // Если marks пусты — fallback на now + 1 day pad чтобы slider не вырождался.
// scheduled records на дату дальше 1 день были недоступны через slider
// (user не мог время-travel'ить чтобы их увидеть).
//
// Default future window = 30 days, чтобы планирование на месяц вперёд
// (типичный business horizon для NSI updates) было без ручного URL hack.
const range = useMemo(() => { const range = useMemo(() => {
const now = Date.now() const now = Date.now()
const DAY_MS = 86_400_000 const DAY_MS = 86_400_000
const FUTURE_WINDOW_MS = 30 * DAY_MS
let minMark = marks.length > 0 ? Math.min(...marks) : now - 365 * DAY_MS let minMark = marks.length > 0 ? Math.min(...marks) : now - 365 * DAY_MS
let maxMark = marks.length > 0 ? Math.max(...marks) : now let maxMark = marks.length > 0 ? Math.max(...marks) : now
@@ -60,8 +58,9 @@ export function TimeTravelPicker({
minMark = Math.min(minMark, valueDate.getTime()) minMark = Math.min(minMark, valueDate.getTime())
maxMark = Math.max(maxMark, valueDate.getTime()) maxMark = Math.max(maxMark, valueDate.getTime())
} }
// Push max forward: at least now + 30 days, и further если есть будущие marks. // Ensure max ≥ now чтобы «сейчас» всегда был достижим, even если все
const effectiveMax = Math.max(maxMark, now + FUTURE_WINDOW_MS) // marks в прошлом (закрытый dict).
const effectiveMax = Math.max(maxMark, now)
const span = effectiveMax - minMark const span = effectiveMax - minMark
const pad = Math.max(span * 0.05, DAY_MS) // ≥1 день const pad = Math.max(span * 0.05, DAY_MS) // ≥1 день
return { min: minMark - pad, max: effectiveMax + pad } return { min: minMark - pad, max: effectiveMax + pad }