fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)

Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
This commit is contained in:
2026-09-29 22:39:57 +02:00
parent 67c9f10153
commit aac79ffa53
105 changed files with 1001 additions and 494 deletions
+71 -28
View File
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RELATIVE_UNITS,
formatBytes,
formatCompact,
formatCountOrDash,
formatDecimal,
formatMoney,
formatDate,
formatDateTime,
formatDateTimeSec,
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn);
i18nStub.language = "hu";
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu);
});
}
it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
i18nStub.language = "en";
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
});
it("clamps a future timestamp instead of counting up", () => {
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow");
});
it("renders nothing for a timestamp it cannot read", () => {
expect(relativeTime("not-a-date")).toBe("");
expect(relativeTime(null)).toBe("");
expect(relativeFromMs(NaN)).toBe("");
expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
expect(relativeTime(null, i18nStub.language)).toBe("");
expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
});
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
@@ -135,9 +140,9 @@ describe("formatNumber", () => {
});
it("groups in the UI language, not in the runtime's", () => {
const asEnglish = formatNumber(1234567);
const asEnglish = formatNumber(1234567, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatNumber(1234567);
const asHungarian = formatNumber(1234567, i18nStub.language);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
@@ -145,24 +150,26 @@ describe("formatNumber", () => {
});
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
i18nStub.language = "hu";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
i18nStub.language = "en";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
});
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
expect(formatNumber(null)).toBe("");
expect(formatNumber(undefined)).toBe("");
expect(formatNumber(NaN)).toBe("");
expect(formatNumber(Infinity)).toBe("");
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
expect(formatNumber(null, i18nStub.language)).toBe("");
expect(formatNumber(undefined, i18nStub.language)).toBe("");
expect(formatNumber(NaN, i18nStub.language)).toBe("");
expect(formatNumber(Infinity, i18nStub.language)).toBe("");
expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one
});
it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu";
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe(
`${new Intl.NumberFormat("hu").format(12345)} ó`,
);
});
});
@@ -185,18 +192,18 @@ describe("formatRuntime", () => {
];
it.each(CASES)("formats %o as %o", (sec, expected) => {
expect(formatRuntime(sec)).toBe(expected);
expect(formatRuntime(sec, i18nStub.language)).toBe(expected);
});
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
i18nStub.language = "hu";
expect(formatRuntime(5025)).toBe("1 ó 23 p");
expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
i18nStub.language = "en";
});
it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) {
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min");
}
});
});
@@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => {
it("formats a timestamp in the UI language, not in the runtime's", () => {
const iso = "2026-09-17T08:30:14Z";
const asEnglish = formatDateTime(iso);
const asEnglish = formatDateTime(iso, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatDateTime(iso);
const asHungarian = formatDateTime(iso, i18nStub.language);
expect(asEnglish).not.toBe(asHungarian);
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
});
@@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => {
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
process.env.TZ = "UTC";
const iso = "2026-09-17T08:30:14Z";
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
i18nStub.language = "hu";
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30");
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
});
@@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => {
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
});
});
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
// asserted and differ, so an implementation that ignores the language cannot pass.
describe("numbers in the UI language", () => {
it("writes a rating with the language's decimal mark", () => {
expect(formatDecimal(8.25, "en")).toBe("8.3");
expect(formatDecimal(8.25, "hu")).toBe("8,3");
expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal
expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one
});
it("writes a big count compact, with the language's suffix", () => {
// Intl keeps the number and its suffix together with a no-break space
const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " ");
expect(formatCompact(1234, "en")).toBe("1.2K");
expect(hu(1234)).toBe("1,2 E");
expect(formatCompact(3_400_000, "en")).toBe("3.4M");
expect(hu(3_400_000)).toBe("3,4 M");
expect(formatCompact(999, "hu")).toBe("999");
expect(formatCompact(null, "hu")).toBe("");
expect(formatCountOrDash(null, "hu")).toBe("—");
});
it("writes a file size with the language's decimal mark", () => {
expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB");
expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB");
expect(formatBytes(501747, "hu")).toBe("490 KB");
expect(formatBytes(0, "hu")).toBe("0 B");
});
it("writes a dollar sum compact, never padded", () => {
expect(formatMoney(160_000_000, "en")).toBe("$160M");
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
});
});