Backend rows store timestamps as Unix SECONDS (time.time() REAL columns:
generation_history, dub_history, exports, longform jobs, projects), while
frontend-local records carry milliseconds (Date.now() story projects) or ISO
strings (transcriptions). Projects/OmniDrive fed the seconds straight into a
millisecond-based diff (fmtTime), so every generation-history card rendered
as ~1970 ("20617d ago") — and, because the same raw value drove the recency
sort, history items also sank to the bottom of the drive.
Fix the class, not the label: a single shared, unit-tolerant normalizer
(frontend/src/utils/relativeTime.js) now backs every relative-time call site.
- toMillis(ts): numbers < 1e12 are seconds (×1000), >= 1e12 already ms; ISO
and numeric strings parse; Date instances pass through; null/0/undefined/
garbage -> null. Backend storage format is untouched (backward compat).
- timeAgo(ts): "—" for missing stamps (never an epoch age), "just now" for
future stamps within 1 min of clock skew, s/m/h/d buckets, short absolute
date beyond 7 days.
- absoluteTime(ts): unit-safe tooltip text, '' when missing (no more
"Jan 1 1970" titles on null rows).
Converted call sites: pages/Projects.jsx (drop local fmtTime + per-source
*1000 juggling; normalize ts once so sort and label agree), components/
Sidebar.jsx + components/WorkspaceProjects.jsx (drop duplicated local
timeAgo copies and caller-side *1000), pages/BatchQueue.jsx (drop local
formatAge; missing created_at used to render an epoch date), pages/
Transcriptions.jsx + components/TranscriptionPicker.jsx (parse via
toMillis, keep their i18n labels; unparseable stamps no longer render
"Invalid Date").
Tests (fail-before/pass-after): utils/relativeTime.test.js covers seconds/
ms/ISO/numeric-string/Date inputs, null/0 -> "—", clock-skew "just now",
and the 1970 regression (a seconds stamp from today must not render as
thousands of days ago); test/ProjectsRelativeTime.test.jsx guards the
OmniDrive wiring end-to-end (seconds created_at renders "2h ago", null
renders "—", mixed-unit sort orders by real recency). Full frontend suite:
106 files / 843 tests green; oxlint, oxfmt, typecheck:ci, node:test green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
89 lines
3.4 KiB
JavaScript
89 lines
3.4 KiB
JavaScript
/**
|
|
* Shared, unit-tolerant timestamp normalization + relative-time formatting.
|
|
*
|
|
* Root cause of the "20617d ago" bug class: the backend stores timestamps as
|
|
* Unix SECONDS (`time.time()` → REAL columns: generation_history.created_at,
|
|
* dub_history, exports, longform jobs, projects…), while frontend-local
|
|
* records carry MILLISECONDS (`Date.now()` — story projects) or ISO strings
|
|
* (transcriptions). Any formatter that assumes one unit renders the other as
|
|
* ~1970 ("20617d ago") or an epoch date ("Jan 1, 1970"). Every timestamp
|
|
* render must funnel through toMillis() so no individual view can regress.
|
|
*
|
|
* Do NOT change the backend's stored format — existing user DBs hold seconds.
|
|
*/
|
|
|
|
// Numeric timestamps below this are seconds, at/above it milliseconds.
|
|
// 1e12 ms = Sep 2001; 1e12 s = year 33658 — unambiguous for real data.
|
|
const MS_THRESHOLD = 1e12;
|
|
|
|
const SHORT_DATE_OPTS = { month: 'short', day: 'numeric' };
|
|
|
|
/**
|
|
* Normalize any timestamp shape to epoch milliseconds, or null when missing/
|
|
* unparseable. Accepts: Unix seconds (float), epoch ms, numeric strings of
|
|
* either, ISO/date strings, Date instances. 0 / null / undefined / '' are
|
|
* treated as "missing" (0 is this codebase's missing-timestamp sentinel —
|
|
* never a real 1970 record).
|
|
*/
|
|
export function toMillis(ts) {
|
|
if (ts == null || ts === 0 || ts === '') return null;
|
|
if (ts instanceof Date) {
|
|
const t = ts.getTime();
|
|
return Number.isNaN(t) ? null : t;
|
|
}
|
|
if (typeof ts === 'string') {
|
|
const trimmed = ts.trim();
|
|
if (!trimmed) return null;
|
|
// Numeric strings ("1751600000", "1751600000000.5") are Unix stamps,
|
|
// not date strings — Date.parse would reject or misread them.
|
|
const asNum = Number(trimmed);
|
|
if (Number.isFinite(asNum)) return toMillis(asNum);
|
|
const parsed = Date.parse(trimmed);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
if (typeof ts !== 'number' || !Number.isFinite(ts) || ts <= 0) return null;
|
|
return Math.round(ts < MS_THRESHOLD ? ts * 1000 : ts);
|
|
}
|
|
|
|
// Future stamps within this window are clock skew, not data corruption.
|
|
const CLOCK_SKEW_MS = 60_000;
|
|
|
|
/**
|
|
* Relative-time label for record timestamps (any unit — see toMillis).
|
|
* - missing/unparseable → "—" (never "20617d ago" / epoch dates)
|
|
* - future within 1 min (clock skew) → "just now"
|
|
* - <60s → "Ns ago"; <60m → "Nm ago"; <24h → "Nh ago"; <7d → "Nd ago"
|
|
* - older (or far-future, i.e. bad clock) → short absolute date
|
|
*/
|
|
export function timeAgo(ts) {
|
|
const ms = toMillis(ts);
|
|
if (ms == null) return '—';
|
|
const diff = Date.now() - ms;
|
|
if (diff < -CLOCK_SKEW_MS) return shortDate(ms);
|
|
if (diff < 0) return 'just now';
|
|
const s = Math.floor(diff / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
const d = Math.floor(h / 24);
|
|
if (d < 7) return `${d}d ago`;
|
|
return shortDate(ms);
|
|
}
|
|
|
|
/** Absolute host-locale datetime for tooltips; '' when missing/unparseable. */
|
|
export function absoluteTime(ts) {
|
|
const ms = toMillis(ts);
|
|
return ms == null ? '' : new Date(ms).toLocaleString();
|
|
}
|
|
|
|
function shortDate(ms) {
|
|
const d = new Date(ms);
|
|
const opts =
|
|
d.getFullYear() === new Date().getFullYear()
|
|
? SHORT_DATE_OPTS
|
|
: { ...SHORT_DATE_OPTS, year: 'numeric' };
|
|
return d.toLocaleDateString([], opts);
|
|
}
|