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
+29 -25
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import i18n from "../i18n";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
@@ -47,14 +47,14 @@ function secsUntil(iso: string | null): number | null {
return Math.round((new Date(iso).getTime() - Date.now()) / 1000);
}
function fmtCountdown(s: number): string {
if (s <= 0) return i18n.t("time.eta.now");
function fmtCountdown(t: TFunction, s: number, lang: string): string {
if (s <= 0) return t("time.eta.now");
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration.
if (s < 60) return formatUnit(s, "second");
if (s < 60) return formatUnit(s, "second", lang);
const m = Math.floor(s / 60);
if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`;
if (m < 60) return `${formatUnit(m, "minute", lang)} ${formatUnit(s % 60, "second", lang)}`;
const h = Math.floor(m / 60);
return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`;
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
}
const DOT_CLASS: Record<string, string> = {
@@ -104,7 +104,8 @@ function StatusLegend() {
// label and an indeterminate sliver — so every active run is visible, not just the ones
// that report numbers.
function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
const pct =
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
@@ -114,9 +115,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{p?.total != null
? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}`
: p
? formatNumber(p.current)
? formatNumber(p.current, lang)
: ""}
</span>
</div>
@@ -152,7 +153,8 @@ function JobRow({
/** A run-all in flight: transient, natively disabled. */
runBusy: boolean;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// The box shows the server's value and holds a draft only while it is being edited, so there is
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
@@ -247,17 +249,17 @@ function JobRow({
</div>
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished)) || undefined}
title={(!job.running && formatDateTime(job.last_finished, lang)) || undefined}
>
{job.running
? t("scheduler.runningNow")
: job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-danger"> · {job.last_error}</span>
) : job.result ? (
<span> · {jobResultText(t, job.result)}</span>
<span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
@@ -270,7 +272,7 @@ function JobRow({
) : next != null ? (
<>
<div className="uppercase tracking-wide text-3xs">{t("scheduler.nextRun")}</div>
<div>{fmtCountdown(next)}</div>
<div>{fmtCountdown(t, next, lang)}</div>
</>
) : (
"—"
@@ -303,7 +305,8 @@ function MaintenanceSettings({
onSave: (batch: number) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
// the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
// `editing` guard at all.
@@ -363,7 +366,7 @@ function MaintenanceSettings({
)}
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
default: formatNumber(m.revalidate_batch_default),
default: formatNumber(m.revalidate_batch_default, lang),
})}
</div>
</div>
@@ -406,7 +409,8 @@ function Stat({
}
export default function Scheduler() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when
@@ -647,31 +651,31 @@ export default function Scheduler() {
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
value={formatNumber(data.queue.channels_recent_pending)}
value={formatNumber(data.queue.channels_recent_pending, lang)}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={formatNumber(data.queue.channels_deep_pending)}
value={formatNumber(data.queue.channels_deep_pending, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={formatNumber(data.queue.videos_pending_enrich)}
value={formatNumber(data.queue.videos_pending_enrich, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={formatNumber(data.queue.videos_pending_shorts)}
value={formatNumber(data.queue.videos_pending_shorts, lang)}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={formatNumber(data.queue.videos_live_refresh)}
value={formatNumber(data.queue.videos_live_refresh, lang)}
/>
</div>
</div>
@@ -684,15 +688,15 @@ export default function Scheduler() {
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{t("scheduler.quotaUsed", {
used: formatNumber(data.quota.used_today),
budget: formatNumber(data.quota.daily_budget),
used: formatNumber(data.quota.used_today, lang),
budget: formatNumber(data.quota.daily_budget, lang),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: formatNumber(data.quota.remaining_today),
formatted: formatNumber(data.quota.remaining_today, lang),
})}
</span>
</div>