From 845f45fb0bc8a11e66a133060c314030a8ecf236 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 12:01:13 -0400 Subject: [PATCH] =?UTF-8?q?refactor(web):=20one=20relativeTime=20for=20the?= =?UTF-8?q?=20triage=20surfaces=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin quarantine, admin playback-errors and library/hidden each carried a byte-identical private copy of the same coarse "3d ago / 5h ago / 12m ago / just now" formatter. Writing the missing-files surface would have made it four, so extract it instead. They are one concept, not three that happen to look alike: each shows the age of something an operator is deciding about, and they have to agree -- a row reading "2d ago" on one screen and "2 days" on another makes the reader wonder whether the two mean different things. Three near neighbours are deliberately NOT folded in, because they are different intents rather than drifted copies: - HistoryRow shows a weekday and clock time under a week ("Tue 21:40"): for listening history, WHEN you played something beats how long ago. - ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and falls back to a locale date past 30 days -- a security surface where the longer form reads better. - PlaylistCard.refreshedLabel() is day-boundary aware and prefixed ("Refreshed today"), and already carries a comment saying it is deliberately not the m/h-ago style. Merging any of those would mean forcing one caller's wording onto another, which is the wrong-abstraction failure, so they stay put. Tests pin the boundaries the copies never covered: each unit step, that only the largest whole unit is reported (25h is "1d ago", never "1d 1h ago"), and that a future timestamp from a skewed client clock degrades to "just now" instead of rendering a negative age. --- web/src/lib/utils/relativeTime.test.ts | 45 +++++++++++++++++++ web/src/lib/utils/relativeTime.ts | 27 +++++++++++ .../routes/admin/playback-errors/+page.svelte | 12 +---- web/src/routes/admin/quarantine/+page.svelte | 12 +---- web/src/routes/library/hidden/+page.svelte | 12 +---- 5 files changed, 75 insertions(+), 33 deletions(-) create mode 100644 web/src/lib/utils/relativeTime.test.ts create mode 100644 web/src/lib/utils/relativeTime.ts diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts new file mode 100644 index 00000000..05fc20da --- /dev/null +++ b/web/src/lib/utils/relativeTime.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { relativeTime } from './relativeTime'; + +// Fixed "now" so the thresholds are exercised deterministically rather than +// against the wall clock, which would make the minute boundary flaky. +const NOW = new Date('2026-08-16T12:00:00Z'); + +function ago(ms: number): string { + return new Date(NOW.getTime() - ms).toISOString(); +} + +afterEach(() => vi.useRealTimers()); + +describe('relativeTime', () => { + test.each([ + ['under a minute', 30 * 1_000, 'just now'], + ['exactly a minute', 60 * 1_000, '1m ago'], + ['minutes', 42 * 60 * 1_000, '42m ago'], + ['exactly an hour', 3_600_000, '1h ago'], + ['hours', 5 * 3_600_000, '5h ago'], + ['exactly a day', 24 * 3_600_000, '1d ago'], + ['days', 9 * 24 * 3_600_000, '9d ago'] + ])('%s', (_label, delta, expected) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(ago(delta))).toBe(expected); + }); + + // The unit steps down at each boundary rather than compounding, so 25 + // hours is "1d ago" and never "1d 1h ago". + test('reports only the largest whole unit', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(ago(25 * 3_600_000))).toBe('1d ago'); + expect(relativeTime(ago(90 * 60 * 1_000))).toBe('1h ago'); + }); + + // A clock skewed behind the server produces a future timestamp; it must + // degrade to "just now" rather than rendering a negative age. + test('a future timestamp reads as just now', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(new Date(NOW.getTime() + 60_000).toISOString())).toBe('just now'); + }); +}); diff --git a/web/src/lib/utils/relativeTime.ts b/web/src/lib/utils/relativeTime.ts new file mode 100644 index 00000000..7ab3398a --- /dev/null +++ b/web/src/lib/utils/relativeTime.ts @@ -0,0 +1,27 @@ +/** + * Coarse "how long ago" label for an ISO timestamp — "3d ago", "5h ago", + * "12m ago", or "just now" under a minute. + * + * The single source for the admin/triage surfaces, which had three + * byte-identical private copies of this before it was extracted (admin + * quarantine, admin playback-errors, library/hidden). Each showed the age + * of something an operator is deciding about, so they must read the same + * — a row that says "2d ago" on one screen and "2 days" on another makes + * the reader wonder whether they mean different things. + * + * Deliberately NOT the formatter used by listening history. HistoryRow + * shows a weekday and clock time for anything under a week ("Tue 21:40") + * because when you played something is more useful than how long ago, + * and it floors at "1m ago" rather than "just now". That is a different + * intent, not a copy that drifted, and it stays where it is. + */ +export function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `${minutes}m ago`; + return 'just now'; +} diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte index d43993f0..0008619a 100644 --- a/web/src/routes/admin/playback-errors/+page.svelte +++ b/web/src/routes/admin/playback-errors/+page.svelte @@ -13,6 +13,7 @@ import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types'; + import { relativeTime } from '$lib/utils/relativeTime'; // Client-reported playback errors inbox. Two tabs — Unresolved // (default) / Resolved. Per-row: copy details to clipboard, delete @@ -31,17 +32,6 @@ const query = $derived($queryStore); const rows = $derived((query.data ?? []) as AdminPlaybackError[]); - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - // Maps the kind enum to a short readable badge label. function kindLabel(kind: string): string { switch (kind) { diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 9e5b148b..420274c9 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -16,6 +16,7 @@ import { coverUrl } from '$lib/media/covers'; import Modal from '$lib/components/Modal.svelte'; import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types'; + import { relativeTime } from '$lib/utils/relativeTime'; // Aggregated triage queue. One row per track, with per-row resolution // actions: Resolve (clears reports), Delete file (Bronze; modal-confirm), @@ -42,17 +43,6 @@ other: 'Other' }; - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - // Row-level expand state: tracks which rows have their per-user report // details revealed. Keyed by track_id. let expanded = $state>({}); diff --git a/web/src/routes/library/hidden/+page.svelte b/web/src/routes/library/hidden/+page.svelte index f9165925..3566e437 100644 --- a/web/src/routes/library/hidden/+page.svelte +++ b/web/src/routes/library/hidden/+page.svelte @@ -7,6 +7,7 @@ import type { LidarrQuarantineMineRow, LidarrQuarantineReason } from '$lib/api/types'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import { coverUrl } from '$lib/media/covers'; + import { relativeTime } from '$lib/utils/relativeTime'; const client = useQueryClient(); const queryStore = createMyQuarantineQuery(); @@ -21,17 +22,6 @@ other: 'Other' }; - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - async function onUnhide(trackID: string) { try { await unflagTrack(trackID);