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
+34 -20
View File
@@ -25,39 +25,53 @@ export function formatSpeed(bps: number | null | undefined): string {
return `${formatBytes(bps)}/s`;
}
/** Relative-time tiers as [seconds-per-unit, label]. Each label names what its OWN divisor
* produces — pairing a divisor with the NEXT row's label is what silently shifted every tier by
* one from ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up
* the scale), so a row is read whole.
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
* and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
* longer be paired with the wrong label, which is exactly what shifted every tier by one from
* 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
* 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
* ordering, and a translation existing for every label it can emit). */
export const RELATIVE_UNITS: readonly [number, string][] = [
[60, "time.minutesAgo"],
[3600, "time.hoursAgo"],
[86400, "time.daysAgo"],
[604800, "time.weeksAgo"],
[2592000, "time.monthsAgo"],
[31536000, "time.yearsAgo"],
* Module-level because it is constant, and exported so the tests can hold that invariant. */
export const RELATIVE_UNITS: readonly [number, Intl.RelativeTimeFormatUnit][] = [
[60, "minute"],
[3600, "hour"],
[86400, "day"],
[604800, "week"],
[2592000, "month"],
[31536000, "year"],
];
// 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
* client-side notifications whose timestamps are already numbers). One implementation, one
* set of `time.*` strings — shared instead of re-implemented per component. Empty string for a
* timestamp that can't be read, so a caller's `|| "—"` fallback covers it like a missing one. */
* client-side notifications whose timestamps are already numbers). One implementation shared
* instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
* caller's `|| "—"` fallback covers it like a missing one. */
export function relativeFromMs(ms: number): string {
const secs = Math.max(0, (Date.now() - ms) / 1000);
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");
let [div, key] = RELATIVE_UNITS[0];
for (const [unitSecs, unitKey] of RELATIVE_UNITS) {
let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break;
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