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
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
afterEach(cleanup);
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
afterEach(() => {
+3 -2
View File
@@ -132,7 +132,8 @@ export default function DataTable<T>({
// the hidden-filter cue leaves those out, it would name them twice.
surfacedFilters?: string[];
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const initial = loadPersist(persistKey);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey.
@@ -212,7 +213,7 @@ export default function DataTable<T>({
setPage(0);
}, [resetFiltersToken, commitFilters]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string; count?: number }) =>
o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key,
}),
@@ -30,7 +30,7 @@ describe("ItemCount", () => {
render(<ItemCount count={192045} />);
// Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
// so the test asserts ItemCount's formatting path rather than the CI locale.
expect(text()).toBe(`${formatNumber(192045)} items`);
expect(text()).toBe(`${formatNumber(192045, "en")} items`);
});
// The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on
@@ -52,7 +52,9 @@ describe("ItemCount", () => {
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render(<ItemCount count={1_000_000} />);
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
formatNumber(1_000_000, "en"),
);
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
+4 -3
View File
@@ -14,7 +14,8 @@ const OVERFLOW_AT = 1_000_000;
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
// so any surface can reuse it for whatever it counts.
export default function ItemCount({ count }: { count?: number }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
// show the icon until the number arrives.
const label =
@@ -22,12 +23,12 @@ export default function ItemCount({ count }: { count?: number }) {
? ""
: t("common.itemCount", {
count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang),
});
return (
<span
data-testid="item-count"
title={count == null ? undefined : formatNumber(count)}
title={count == null ? undefined : formatNumber(count, lang)}
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
@@ -4,6 +4,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
import "../../i18n"; // the real instance, in English: the component names time in the UI language
import { RelativeTime } from "./RelativeTime";
afterEach(cleanup);
@@ -14,8 +15,8 @@ describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO));
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
});
@@ -23,7 +24,7 @@ describe("RelativeTime", () => {
const ms = Date.parse(THREE_DAYS_AGO);
const { container } = render(<RelativeTime at={ms} seconds />);
expect(container.querySelector("time")!.getAttribute("title")).toBe(
formatDateTimeSec(THREE_DAYS_AGO),
formatDateTimeSec(THREE_DAYS_AGO, "en"),
);
});
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { formatDateTime, formatDateTimeSec, relativeFromMs } from "../../lib/format";
// docs/DESIGN.md "Copy": a relative time ("3d ago") says how recent something is in a list, and the
@@ -16,14 +17,15 @@ export function RelativeTime({
empty?: string;
className?: string;
}) {
const lang = useTranslation().i18n.language;
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
const text = Number.isFinite(ms) ? relativeFromMs(ms) : "";
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : "";
if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString();
return (
<time
dateTime={iso}
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)}
title={seconds ? formatDateTimeSec(iso, lang) : formatDateTime(iso, lang)}
className={className}
>
{text}