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:
+34
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user