fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s

CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.

Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".

That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.

The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n                2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
This commit is contained in:
2026-08-17 00:25:40 -04:00
parent 414dfb23b6
commit 6d729d1512
3 changed files with 37 additions and 16 deletions
+9
View File
@@ -57,6 +57,15 @@ describe('timeUntil', () => {
expect(timeUntil(new Date(NOW.getTime() + delta).toISOString())).toBe(expected);
});
// The bug CI caught: flooring rendered a 4h-away attempt as "in 3h",
// sending the operator back an hour early.
test('rounds rather than floors, so nearly-4h reads as 4h', () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const almost4h = new Date(NOW.getTime() + 4 * 3_600_000 - 2_000).toISOString();
expect(timeUntil(almost4h)).toBe('in 4h');
});
// A due-or-overdue attempt is the sweeper's next tick away, not "3h ago" —
// the operator wants to know it is imminent, not how late it is.
test('a moment already passed reads as imminent', () => {
+11 -8
View File
@@ -38,12 +38,15 @@ export function relativeTime(iso: string): string {
*/
export function timeUntil(iso: string): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'any moment';
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `in ${days}d`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `in ${hours}h`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `in ${minutes}m`;
return 'any moment';
if (ms < 60_000) return 'any moment';
// Rounds where relativeTime floors, and the difference matters. Flooring an
// elapsed time is honest — "3h ago" for 3h59m means "at least three hours".
// Flooring a countdown is not: it would show "in 3h" for something 3h59m
// away, so the operator comes back an hour early and finds nothing has
// happened. Caught by CI rendering a 4h-away attempt as "in 3h".
const minutes = Math.round(ms / 60_000);
if (minutes < 60) return `in ${minutes}m`;
const hours = Math.round(ms / 3_600_000);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(ms / (24 * 3_600_000))}d`;
}