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
+25 -19
View File
@@ -66,7 +66,8 @@ export default function Stats() {
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
const { openWizard } = useWizard();
@@ -125,14 +126,14 @@ function Overview({ me }: { me: Me }) {
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
{formatEta(t, s.deep_eta_seconds)}
</SettingRow>
)}
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{formatNumber(s.my_videos)}
{formatNumber(s.my_videos, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{formatNumber(s.quota_remaining_today)}
{formatNumber(s.quota_remaining_today, lang)}
</SettingRow>
</div>
) : status.isError ? (
@@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) {
<>
<div className="space-y-1.5 text-sm">
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{formatNumber(usage.data.today)}
{formatNumber(usage.data.today, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>
{formatNumber(usage.data.last_7d)}
{formatNumber(usage.data.last_7d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>
{formatNumber(usage.data.last_30d)}
{formatNumber(usage.data.last_30d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>
{formatNumber(usage.data.all_time)}
{formatNumber(usage.data.all_time, lang)}
</SettingRow>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
@@ -168,8 +169,8 @@ function Overview({ me }: { me: Me }) {
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>
@@ -218,7 +219,8 @@ function Overview({ me }: { me: Me }) {
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
function SystemStats() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [days, setDays] = useState<number>(30);
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
@@ -263,7 +265,7 @@ function SystemStats() {
className="flex items-center gap-1.5"
options={RANGES.map((d) => ({
value: String(d),
label: t("stats.rangeDays", { d: formatUnit(d, "day") }),
label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
}))}
value={String(days)}
onChange={(v) => setDays(Number(v))}
@@ -290,8 +292,8 @@ function SystemStats() {
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
d: formatUnit(data.range_days, "day"),
units: formatNumber(grandTotal),
d: formatUnit(data.range_days, "day", lang),
units: formatNumber(grandTotal, lang),
})}
</div>
{data.daily.length === 0 ? (
@@ -304,7 +306,10 @@ function SystemStats() {
// The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })}
title={t("stats.dailyTooltip", {
day: d.day,
units: formatNumber(d.total, lang),
})}
>
<div
className="w-full bg-accent rounded-t"
@@ -333,7 +338,8 @@ function SystemStats() {
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
@@ -349,7 +355,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total)}
{formatNumber(row.total, lang)}
</span>
</Pressable>
{/* Proportion bar */}
@@ -363,8 +369,8 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>