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
+48 -15
View File
@@ -82,33 +82,66 @@ const focusSafeDisabled = {
};
},
};
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
// number goes through formatNumber, which the same rule covers).
const dateFormat = {
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere.
//
// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton
// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read
// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to
// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes.
const localeFormat = {
meta: {
type: "problem",
messages: {
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.",
order:
"Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.",
singleton:
"Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
if (/\.test\.tsx?$/.test(path)) return {};
// lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
// is a clock face with its own 12/24h switch, not a date.
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
const collates = /\/src\/lib\/collate\.ts$/.test(path);
const component = /\/src\/components\//.test(path);
const importedFromI18n = (node) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
const v = scope.set.get(node.name);
if (v)
return v.defs.some(
(d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value),
);
}
return false;
};
return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node,
) {
context.report({ node, messageId: "date" });
if (!formats) context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"(
node,
) {
context.report({ node, messageId: "date" });
if (!formats) context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name='Collator']"(node) {
if (!collates) context.report({ node, messageId: "order" });
},
"CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) {
context.report({ node, messageId: "order" });
},
"MemberExpression[property.name='language'][object.type='Identifier']"(node) {
if (component && importedFromI18n(node.object))
context.report({ node, messageId: "singleton" });
},
};
},
@@ -382,7 +415,7 @@ const localRules = {
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"date-format": dateFormat,
"locale-format": localeFormat,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
@@ -477,7 +510,7 @@ export default tseslint.config(
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/date-format": "error",
"local/locale-format": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",