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
+14 -7
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { formatDate, formatDateTime } from "../lib/format";
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
@@ -245,7 +245,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
}
function Notifications({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
// it. The "send test" button below fires immediately.
const { notif: cfg, setNotif } = prefs;
@@ -270,7 +271,9 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
<span className="text-muted text-xs">
{formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
</span>
</div>
<Slider
className="mt-1"
@@ -522,7 +525,8 @@ function SignInMethods({ me }: { me: Me }) {
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
@@ -565,7 +569,9 @@ function PlexWatchSync() {
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null;
const last = link.data?.last_watch_sync_at
? formatDateTime(link.data.last_watch_sync_at, lang)
: null;
return (
<Section title={t("settings.plexSync.title")}>
@@ -765,7 +771,8 @@ export function DownloadAuth() {
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
* not recognise is something they can see. */
export function AccountHeader({ me }: { me: Me }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) {
{!me.is_demo && (
<div className="text-xs text-muted" data-testid="account-last-login">
{me.last_login_at
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) })
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
: t("settings.account.lastLoginNone")}
</div>
)}