refactor(web): one relativeTime for the triage surfaces — #2527
test-web / test (push) Successful in 40s
test-web / test (push) Successful in 40s
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.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user