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:
@@ -3,15 +3,12 @@ import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
|
||||
import en from "../i18n/locales/en/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
|
||||
// echo `key:count`. That is exactly what these cases need to lock: which LABEL a range is paired
|
||||
// with. The bug this suite exists for had every label one tier too small — right number, wrong
|
||||
// word ("4 min ago" rendered as "4s ago").
|
||||
vi.mock("../i18n", () => ({
|
||||
default: {
|
||||
t: (key: string, opts?: { count?: number }) => (opts ? `${key}:${opts.count}` : key),
|
||||
},
|
||||
}));
|
||||
// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it.
|
||||
// `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these
|
||||
// cases assert both languages by flipping it. `t` echoes the key: the only string still coming
|
||||
// from the locale files is justNow.
|
||||
const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key }));
|
||||
vi.mock("../i18n", () => ({ default: i18nStub }));
|
||||
|
||||
const MIN = 60;
|
||||
const HOUR = 3600;
|
||||
@@ -20,28 +17,31 @@ const WEEK = 604800;
|
||||
const MONTH = 2592000;
|
||||
const YEAR = 31536000;
|
||||
|
||||
// [name, age in seconds, expected label]. Every tier is sampled both at its exact opening second
|
||||
// and somewhere inside it — an interior-only suite still passes if the comparison flips to `<=`.
|
||||
const CASES: [string, number, string][] = [
|
||||
["just under a minute", MIN - 1, "time.justNow"],
|
||||
["exactly a minute", MIN, "time.minutesAgo:1"],
|
||||
["a minute and a half", 90, "time.minutesAgo:1"],
|
||||
["the last second before an hour", HOUR - 1, "time.minutesAgo:59"],
|
||||
["exactly an hour", HOUR, "time.hoursAgo:1"],
|
||||
["two hours", 2 * HOUR, "time.hoursAgo:2"],
|
||||
["the last second before a day", DAY - 1, "time.hoursAgo:23"],
|
||||
["exactly a day", DAY, "time.daysAgo:1"],
|
||||
["just past a day", 25 * HOUR, "time.daysAgo:1"],
|
||||
["the last second before a week", WEEK - 1, "time.daysAgo:6"],
|
||||
["exactly a week", WEEK, "time.weeksAgo:1"],
|
||||
["eight days", 8 * DAY, "time.weeksAgo:1"],
|
||||
["the last second before a month", MONTH - 1, "time.weeksAgo:4"],
|
||||
["exactly a month", MONTH, "time.monthsAgo:1"],
|
||||
["forty days", 40 * DAY, "time.monthsAgo:1"],
|
||||
["the last second before a year", YEAR - 1, "time.monthsAgo:12"],
|
||||
["exactly a year", YEAR, "time.yearsAgo:1"],
|
||||
["four hundred days", 400 * DAY, "time.yearsAgo:1"],
|
||||
["a decade", 10 * YEAR, "time.yearsAgo:10"],
|
||||
// [age in seconds, expected tier, expected en, expected hu]. Every tier is sampled at its exact
|
||||
// opening second and inside it — an interior-only suite still passes if the range comparison
|
||||
// flips to `<=`. The wording comes from Intl/CLDR rather than from us, so a failure here after a
|
||||
// runtime upgrade means the platform's copy moved, not that tier selection broke; the tier column
|
||||
// is what pins our own logic.
|
||||
const CASES: [number, string, string, string][] = [
|
||||
[MIN - 1, "justNow", "time.justNow", "time.justNow"],
|
||||
[MIN, "minute", "1m ago", "1 perce"],
|
||||
[90, "minute", "1m ago", "1 perce"],
|
||||
[HOUR - 1, "minute", "59m ago", "59 perce"],
|
||||
[HOUR, "hour", "1h ago", "1 órája"],
|
||||
[2 * HOUR, "hour", "2h ago", "2 órája"],
|
||||
[DAY - 1, "hour", "23h ago", "23 órája"],
|
||||
[DAY, "day", "1d ago", "1 napja"],
|
||||
[25 * HOUR, "day", "1d ago", "1 napja"],
|
||||
[WEEK - 1, "day", "6d ago", "6 napja"],
|
||||
[WEEK, "week", "1w ago", "1 hete"],
|
||||
[8 * DAY, "week", "1w ago", "1 hete"],
|
||||
[MONTH - 1, "week", "4w ago", "4 hete"],
|
||||
[MONTH, "month", "1mo ago", "1 hónapja"],
|
||||
[40 * DAY, "month", "1mo ago", "1 hónapja"],
|
||||
[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.
|
||||
@@ -51,17 +51,29 @@ describe("relativeFromMs", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
i18nStub.language = "en";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
for (const [name, secs, expected] of CASES) {
|
||||
it(`${name} → ${expected}`, () => {
|
||||
expect(relativeFromMs(NOW - secs * 1000)).toBe(expected);
|
||||
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
|
||||
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
|
||||
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", () => {
|
||||
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
|
||||
});
|
||||
@@ -72,8 +84,8 @@ describe("relativeFromMs", () => {
|
||||
expect(relativeFromMs(NaN)).toBe("");
|
||||
});
|
||||
|
||||
// The three below are derived from RELATIVE_UNITS, not from CASES, so adding a tier to the
|
||||
// table without covering it here fails rather than slipping through unnoticed.
|
||||
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
|
||||
// it here fails rather than slipping through unnoticed.
|
||||
it("keeps the unit table sorted ascending", () => {
|
||||
const divisors = RELATIVE_UNITS.map(([secs]) => secs);
|
||||
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", () => {
|
||||
const covered = new Set(CASES.map(([, , label]) => label.split(":")[0]));
|
||||
for (const [, key] of RELATIVE_UNITS) {
|
||||
expect(covered, `no case covers ${key}`).toContain(key);
|
||||
const covered = new Set(CASES.map(([, tier]) => tier));
|
||||
for (const [, unit] of RELATIVE_UNITS) {
|
||||
expect(covered, `no case covers ${unit}`).toContain(unit);
|
||||
}
|
||||
});
|
||||
|
||||
it("translates every label the table can emit, in both languages", () => {
|
||||
const keys = ["justNow", ...RELATIVE_UNITS.map(([, key]) => key.replace("time.", ""))];
|
||||
for (const key of keys) {
|
||||
expect(en, `en/time.json is missing ${key}`).toHaveProperty(key);
|
||||
expect(hu, `hu/time.json is missing ${key}`).toHaveProperty(key);
|
||||
}
|
||||
it("still has the one string Intl cannot produce, in both languages", () => {
|
||||
expect(en).toHaveProperty("justNow");
|
||||
expect(hu).toHaveProperty("justNow");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user