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);