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:
+98
-57
@@ -1,12 +1,36 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export function relativeTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
return relativeFromMs(new Date(iso).getTime());
|
||||
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
|
||||
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
|
||||
//
|
||||
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style
|
||||
// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language
|
||||
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
|
||||
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
|
||||
// dependency the compiler can see; the signature makes forgetting it a type error.
|
||||
|
||||
// Intl formatters are expensive to build and every grid formats one per card: one per language ×
|
||||
// options, built lazily. Keyed by language because the user can switch it at runtime.
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat {
|
||||
const key = `${lang}|${JSON.stringify(opts)}`;
|
||||
let f = numberFormats.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat(lang, opts);
|
||||
numberFormats.set(key, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
export function relativeTime(iso: string | null, lang: string): string {
|
||||
if (!iso) return "";
|
||||
return relativeFromMs(new Date(iso).getTime(), lang);
|
||||
}
|
||||
|
||||
/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB",
|
||||
* 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */
|
||||
export function formatBytes(bytes: number | null | undefined, lang: string): string {
|
||||
const n = Number(bytes);
|
||||
if (!n || n < 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang);
|
||||
return `${num} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
||||
export function formatSpeed(bps: number | null | undefined): string {
|
||||
export function formatSpeed(bps: number | null | undefined, lang: string): string {
|
||||
if (!bps) return "";
|
||||
return `${formatBytes(bps)}/s`;
|
||||
return `${formatBytes(bps, lang)}/s`;
|
||||
}
|
||||
|
||||
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
|
||||
@@ -65,18 +90,18 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
|
||||
* 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 {
|
||||
export function relativeFromMs(ms: number, lang: string): 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");
|
||||
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang });
|
||||
let [div, unit] = RELATIVE_UNITS[0];
|
||||
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
|
||||
if (secs < unitSecs) break;
|
||||
div = unitSecs;
|
||||
unit = unitName;
|
||||
}
|
||||
return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit);
|
||||
return relativeFormatter(lang).format(-Math.floor(secs / div), unit);
|
||||
}
|
||||
|
||||
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
|
||||
@@ -100,15 +125,17 @@ export function formatDuration(sec: number | null): string {
|
||||
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
|
||||
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
|
||||
export type DurationUnit = "second" | "minute" | "hour" | "day";
|
||||
const unitFormatters = new Map<string, Intl.NumberFormat>();
|
||||
export function formatUnit(value: number, unit: DurationUnit): string {
|
||||
const key = `${i18n.language}|${unit}`;
|
||||
let f = unitFormatters.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
|
||||
unitFormatters.set(key, f);
|
||||
}
|
||||
return f.format(value);
|
||||
export function formatUnit(
|
||||
value: number,
|
||||
unit: DurationUnit,
|
||||
lang: string,
|
||||
digits?: number,
|
||||
): string {
|
||||
const fraction =
|
||||
digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits };
|
||||
return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
|
||||
@@ -118,70 +145,84 @@ export function formatUnit(value: number, unit: DurationUnit): string {
|
||||
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
|
||||
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a
|
||||
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
|
||||
export function formatRuntime(sec?: number | null): string {
|
||||
export function formatRuntime(sec: number | null | undefined, lang: string): string {
|
||||
if (!sec) return "";
|
||||
if (sec < 60) return formatUnit(Math.floor(sec), "second");
|
||||
if (sec < 60) return formatUnit(Math.floor(sec), "second", lang);
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
|
||||
return h
|
||||
? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}`
|
||||
: formatUnit(m, "minute", lang);
|
||||
}
|
||||
|
||||
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
||||
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
||||
// key for any unmapped/legacy value.
|
||||
export function quotaActionLabel(action: string): string {
|
||||
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
|
||||
export function quotaActionLabel(t: TFunction, action: string): string {
|
||||
return t(`quotaActions.${action}`, { defaultValue: action });
|
||||
}
|
||||
|
||||
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
|
||||
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds <= 0) return i18n.t("time.eta.done");
|
||||
export function formatEta(t: TFunction, seconds: number): string {
|
||||
if (seconds <= 0) return t("time.eta.done");
|
||||
const hours = seconds / 3600;
|
||||
if (hours < 1) return i18n.t("time.eta.lessThanHour");
|
||||
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
|
||||
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
|
||||
if (hours < 1) return t("time.eta.lessThanHour");
|
||||
if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) });
|
||||
return t("time.eta.days", { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */
|
||||
export function formatTotalHours(sec: number): string {
|
||||
export function formatTotalHours(sec: number, lang: string): string {
|
||||
if (!sec) return "—";
|
||||
const h = Math.round(sec / 3600);
|
||||
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
|
||||
return h >= 1
|
||||
? formatUnit(h, "hour", lang)
|
||||
: formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang);
|
||||
}
|
||||
|
||||
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
|
||||
// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
|
||||
// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
|
||||
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
|
||||
* `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
|
||||
* browser showed space-grouped figures and vice versa — the number half of the same defect
|
||||
* `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
|
||||
* `|| "—"` fallback covers it like a missing one. */
|
||||
export function formatNumber(n: number | null | undefined): string {
|
||||
export function formatNumber(n: number | null | undefined, lang: string): string {
|
||||
if (n == null || !Number.isFinite(n)) return "";
|
||||
const lang = i18n.language;
|
||||
let formatter = numberFormatters.get(lang);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(lang);
|
||||
numberFormatters.set(lang, formatter);
|
||||
}
|
||||
return formatter.format(n);
|
||||
return numberFormat(lang).format(n);
|
||||
}
|
||||
|
||||
export function formatViews(n: number | null): string {
|
||||
if (n == null) return "";
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
||||
return String(n);
|
||||
/** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed`
|
||||
* always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */
|
||||
export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string {
|
||||
const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits };
|
||||
return numberFormat(lang, opts).format(n);
|
||||
}
|
||||
|
||||
/** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is
|
||||
* pinned for the reason `formatMoney` gives. Empty for null. */
|
||||
export function formatCompact(n: number | null | undefined, lang: string): string {
|
||||
if (n == null || !Number.isFinite(n)) return "";
|
||||
const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const;
|
||||
return numberFormat(lang, opts).format(n);
|
||||
}
|
||||
|
||||
/** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's
|
||||
* language. The minimum is load-bearing, not a default written out: given only a maximum, a compact
|
||||
* format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24
|
||||
* (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */
|
||||
export function formatMoney(n: number, lang: string): string {
|
||||
return numberFormat(lang, {
|
||||
notation: "compact",
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
|
||||
export function formatCountOrDash(n: number | null | undefined): string {
|
||||
return n != null ? formatViews(n) : "—";
|
||||
export function formatCountOrDash(n: number | null | undefined, lang: string): string {
|
||||
return n != null ? formatCompact(n, lang) : "—";
|
||||
}
|
||||
|
||||
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
|
||||
@@ -195,12 +236,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
|
||||
const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
|
||||
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
|
||||
export function formatDateTime(iso: string | null | undefined, lang: string): string {
|
||||
return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
|
||||
}
|
||||
|
||||
export function formatDateTimeSec(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
|
||||
export function formatDateTimeSec(iso: string | null | undefined, lang: string): string {
|
||||
return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : "";
|
||||
}
|
||||
|
||||
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
|
||||
|
||||
Reference in New Issue
Block a user