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';
|
||||
}
|
||||
Reference in New Issue
Block a user