refactor(format): localize relative time with Intl.RelativeTimeFormat

The tier table kept a divisor and a translation key as two independently
maintained halves, and a mispairing there rendered minutes as "Ns ago" for 59
releases. The table now pairs a divisor with the Intl unit it produces and the
platform supplies the words, so the wrong half is at least self-evident on
sight — "minute" next to 3600 reads wrong in a way "time.minutesAgo" never
did. It also removes 12 hand-written strings and hands plural rules to CLDR
for any language added later.

Hungarian output is byte-identical to the strings it replaces. English gets
shorter: "59 min ago" to "59m ago", "1 wk ago" to "1w ago", "1 mo ago" to
"1mo ago", "1 yr ago" to "1y ago"; "2h ago" and "1d ago" are unchanged.

style narrow matches the app's compact timestamps and numeric always keeps it
counting, rather than "yesterday"/"last week". time.justNow stays because Intl
has no wording for the sub-minute case. Formatters are memoized per language,
since this runs once per rendered timestamp across a virtualized feed.
This commit is contained in:
2026-07-20 22:28:09 +02:00
parent dea63d8fd2
commit 7cd5adc78f
4 changed files with 88 additions and 77 deletions
-6
View File
@@ -1,11 +1,5 @@
{ {
"justNow": "just now", "justNow": "just now",
"minutesAgo": "{{count}} min ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago",
"weeksAgo": "{{count}} wk ago",
"monthsAgo": "{{count}} mo ago",
"yearsAgo": "{{count}} yr ago",
"eta": { "eta": {
"now": "now", "now": "now",
"done": "done", "done": "done",
-6
View File
@@ -1,11 +1,5 @@
{ {
"justNow": "épp most", "justNow": "épp most",
"minutesAgo": "{{count}} perce",
"hoursAgo": "{{count}} órája",
"daysAgo": "{{count}} napja",
"weeksAgo": "{{count}} hete",
"monthsAgo": "{{count}} hónapja",
"yearsAgo": "{{count}} éve",
"eta": { "eta": {
"now": "most", "now": "most",
"done": "kész", "done": "kész",
+54 -45
View File
@@ -3,15 +3,12 @@ import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
import en from "../i18n/locales/en/time.json"; import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json"; import hu from "../i18n/locales/hu/time.json";
// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it to // i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it.
// echo `key:count`. That is exactly what these cases need to lock: which LABEL a range is paired // `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these
// with. The bug this suite exists for had every label one tier too small — right number, wrong // cases assert both languages by flipping it. `t` echoes the key: the only string still coming
// word ("4 min ago" rendered as "4s ago"). // from the locale files is justNow.
vi.mock("../i18n", () => ({ const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key }));
default: { vi.mock("../i18n", () => ({ default: i18nStub }));
t: (key: string, opts?: { count?: number }) => (opts ? `${key}:${opts.count}` : key),
},
}));
const MIN = 60; const MIN = 60;
const HOUR = 3600; const HOUR = 3600;
@@ -20,28 +17,31 @@ const WEEK = 604800;
const MONTH = 2592000; const MONTH = 2592000;
const YEAR = 31536000; const YEAR = 31536000;
// [name, age in seconds, expected label]. Every tier is sampled both at its exact opening second // [age in seconds, expected tier, expected en, expected hu]. Every tier is sampled at its exact
// and somewhere inside it — an interior-only suite still passes if the comparison flips to `<=`. // opening second and inside it — an interior-only suite still passes if the range comparison
const CASES: [string, number, string][] = [ // flips to `<=`. The wording comes from Intl/CLDR rather than from us, so a failure here after a
["just under a minute", MIN - 1, "time.justNow"], // runtime upgrade means the platform's copy moved, not that tier selection broke; the tier column
["exactly a minute", MIN, "time.minutesAgo:1"], // is what pins our own logic.
["a minute and a half", 90, "time.minutesAgo:1"], const CASES: [number, string, string, string][] = [
["the last second before an hour", HOUR - 1, "time.minutesAgo:59"], [MIN - 1, "justNow", "time.justNow", "time.justNow"],
["exactly an hour", HOUR, "time.hoursAgo:1"], [MIN, "minute", "1m ago", "1 perce"],
["two hours", 2 * HOUR, "time.hoursAgo:2"], [90, "minute", "1m ago", "1 perce"],
["the last second before a day", DAY - 1, "time.hoursAgo:23"], [HOUR - 1, "minute", "59m ago", "59 perce"],
["exactly a day", DAY, "time.daysAgo:1"], [HOUR, "hour", "1h ago", "1 órája"],
["just past a day", 25 * HOUR, "time.daysAgo:1"], [2 * HOUR, "hour", "2h ago", "2 órája"],
["the last second before a week", WEEK - 1, "time.daysAgo:6"], [DAY - 1, "hour", "23h ago", "23 órája"],
["exactly a week", WEEK, "time.weeksAgo:1"], [DAY, "day", "1d ago", "1 napja"],
["eight days", 8 * DAY, "time.weeksAgo:1"], [25 * HOUR, "day", "1d ago", "1 napja"],
["the last second before a month", MONTH - 1, "time.weeksAgo:4"], [WEEK - 1, "day", "6d ago", "6 napja"],
["exactly a month", MONTH, "time.monthsAgo:1"], [WEEK, "week", "1w ago", "1 hete"],
["forty days", 40 * DAY, "time.monthsAgo:1"], [8 * DAY, "week", "1w ago", "1 hete"],
["the last second before a year", YEAR - 1, "time.monthsAgo:12"], [MONTH - 1, "week", "4w ago", "4 hete"],
["exactly a year", YEAR, "time.yearsAgo:1"], [MONTH, "month", "1mo ago", "1 hónapja"],
["four hundred days", 400 * DAY, "time.yearsAgo:1"], [40 * DAY, "month", "1mo ago", "1 hónapja"],
["a decade", 10 * YEAR, "time.yearsAgo:10"], [YEAR - 1, "month", "12mo ago", "12 hónapja"],
[YEAR, "year", "1y ago", "1 éve"],
[400 * DAY, "year", "1y ago", "1 éve"],
[10 * YEAR, "year", "10y ago", "10 éve"],
]; ];
// Fixed clock: relativeFromMs reads Date.now(), so the age under test has to be exact. // Fixed clock: relativeFromMs reads Date.now(), so the age under test has to be exact.
@@ -51,17 +51,29 @@ describe("relativeFromMs", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
i18nStub.language = "en";
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
for (const [name, secs, expected] of CASES) { for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${name} → ${expected}`, () => { it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
expect(relativeFromMs(NOW - secs * 1000)).toBe(expected); expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
i18nStub.language = "hu";
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
}); });
} }
it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
i18nStub.language = "en";
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
});
it("clamps a future timestamp instead of counting up", () => { it("clamps a future timestamp instead of counting up", () => {
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow"); expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
}); });
@@ -72,8 +84,8 @@ describe("relativeFromMs", () => {
expect(relativeFromMs(NaN)).toBe(""); expect(relativeFromMs(NaN)).toBe("");
}); });
// The three below are derived from RELATIVE_UNITS, not from CASES, so adding a tier to the // Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
// table without covering it here fails rather than slipping through unnoticed. // it here fails rather than slipping through unnoticed.
it("keeps the unit table sorted ascending", () => { it("keeps the unit table sorted ascending", () => {
const divisors = RELATIVE_UNITS.map(([secs]) => secs); const divisors = RELATIVE_UNITS.map(([secs]) => secs);
expect(divisors, "row 0 doubles as the just-now threshold").toEqual( expect(divisors, "row 0 doubles as the just-now threshold").toEqual(
@@ -85,17 +97,14 @@ describe("relativeFromMs", () => {
}); });
it("exercises every tier in the unit table", () => { it("exercises every tier in the unit table", () => {
const covered = new Set(CASES.map(([, , label]) => label.split(":")[0])); const covered = new Set(CASES.map(([, tier]) => tier));
for (const [, key] of RELATIVE_UNITS) { for (const [, unit] of RELATIVE_UNITS) {
expect(covered, `no case covers ${key}`).toContain(key); expect(covered, `no case covers ${unit}`).toContain(unit);
} }
}); });
it("translates every label the table can emit, in both languages", () => { it("still has the one string Intl cannot produce, in both languages", () => {
const keys = ["justNow", ...RELATIVE_UNITS.map(([, key]) => key.replace("time.", ""))]; expect(en).toHaveProperty("justNow");
for (const key of keys) { expect(hu).toHaveProperty("justNow");
expect(en, `en/time.json is missing ${key}`).toHaveProperty(key);
expect(hu, `hu/time.json is missing ${key}`).toHaveProperty(key);
}
}); });
}); });
+34 -20
View File
@@ -25,39 +25,53 @@ export function formatSpeed(bps: number | null | undefined): string {
return `${formatBytes(bps)}/s`; return `${formatBytes(bps)}/s`;
} }
/** Relative-time tiers as [seconds-per-unit, label]. Each label names what its OWN divisor /** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
* produces — pairing a divisor with the NEXT row's label is what silently shifted every tier by * and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
* one from ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up * longer be paired with the wrong label, which is exactly what shifted every tier by one from
* the scale), so a row is read whole. * ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up the scale).
* *
* MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it * MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it
* largest-first — the natural instinct — would make every timestamp under a year read "just now". * largest-first — the natural instinct — would make every timestamp under a year read "just now".
* Module-level because it is constant, and exported so the tests can hold both invariants (the * Module-level because it is constant, and exported so the tests can hold that invariant. */
* ordering, and a translation existing for every label it can emit). */ export const RELATIVE_UNITS: readonly [number, Intl.RelativeTimeFormatUnit][] = [
export const RELATIVE_UNITS: readonly [number, string][] = [ [60, "minute"],
[60, "time.minutesAgo"], [3600, "hour"],
[3600, "time.hoursAgo"], [86400, "day"],
[86400, "time.daysAgo"], [604800, "week"],
[604800, "time.weeksAgo"], [2592000, "month"],
[2592000, "time.monthsAgo"], [31536000, "year"],
[31536000, "time.yearsAgo"],
]; ];
// One formatter per language, built lazily: relativeFromMs runs once per rendered timestamp
// across a virtualized feed, and constructing an Intl formatter is the expensive part. Keyed by
// language because the user can switch it at runtime. `narrow` is the style that matches the
// app's compact timestamps; `numeric: "always"` keeps it counting ("1d ago", never "yesterday").
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
let formatter = relativeFormatters.get(lang);
if (!formatter) {
formatter = new Intl.RelativeTimeFormat(lang, { style: "narrow", numeric: "always" });
relativeFormatters.set(lang, formatter);
}
return formatter;
}
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for /** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
* client-side notifications whose timestamps are already numbers). One implementation, one * client-side notifications whose timestamps are already numbers). One implementation shared
* set of `time.*` strings — shared instead of re-implemented per component. Empty string for a * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
* timestamp that can't be read, so a caller's `|| "—"` fallback covers it like a missing one. */ * caller's `|| "—"` fallback covers it like a missing one. */
export function relativeFromMs(ms: number): string { export function relativeFromMs(ms: number): string {
const secs = Math.max(0, (Date.now() - ms) / 1000); const secs = Math.max(0, (Date.now() - ms) / 1000);
if (!Number.isFinite(secs)) return ""; if (!Number.isFinite(secs)) return "";
// Sub-minute is the one case Intl has no wording for — it would say "0m ago".
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow"); if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow");
let [div, key] = RELATIVE_UNITS[0]; let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitKey] of RELATIVE_UNITS) { for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break; if (secs < unitSecs) break;
div = unitSecs; div = unitSecs;
key = unitKey; unit = unitName;
} }
return i18n.t(key, { count: Math.floor(secs / div) }); return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit);
} }
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the // The canonical YouTube URL for a channel: its @handle when known (nicer), else the