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
+13 -11
View File
@@ -58,6 +58,7 @@ import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { vocab } from "../lib/vocab";
import { formatDate, formatDecimal } from "../lib/format";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -82,8 +83,8 @@ export function PlexInfoView({
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const { t, i18n } = useTranslation();
const lang = plexLang(i18n.language);
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-400 mx-auto">
@@ -137,13 +138,13 @@ export function PlexPersonView({
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
// lists on one screen, and the popover anchors where it opened, so a second controller would only
// mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const lang = plexLang();
const lang = plexLang(i18n.language);
const personKey = qk.plexPerson(name, lang);
const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
@@ -255,12 +256,13 @@ export function PlexPersonView({
{(d.birthday || d.place_of_birth) && (
<p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
{[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")}
</p>
)}
{d.deathday && (
<p>
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
{formatDate(d.deathday, lang)}
</p>
)}
{d.also_known_as.length > 0 && (
@@ -345,10 +347,10 @@ export function PlexShowView({
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const lang = plexLang();
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
@@ -535,7 +537,7 @@ export function PlexShowView({
>
{/* the rating gold — its own token, not --warn: a score is not a warning */}
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)}
{formatDecimal(score, lang)}
</Pressable>
);
})()}
@@ -671,9 +673,9 @@ export function PlexSeasonView({
onBack: () => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),