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
+11 -9
View File
@@ -240,7 +240,8 @@ export function DownloadRow({
more?: ReactNode;
children?: ReactNode;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const running = job.status === "running";
const linkTitle = !hasOwnTitle(job) && !!job.source_url;
const sourceLink = linkTitle ? null : job.source_url;
@@ -294,16 +295,16 @@ export function DownloadRow({
<span className="text-muted uppercase"> · {job.asset.container}</span>
) : null}
{job.asset?.size_bytes ? (
<span className="text-muted"> · {formatBytes(job.asset.size_bytes)}</span>
<span className="text-muted"> · {formatBytes(job.asset.size_bytes, lang)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span className="text-muted" title={formatDateTime(job.created_at)}>
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
{" · "}
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at)}
{relativeTime(job.created_at, lang)}
</span>
) : null}
{job.status === "error" ? (
@@ -360,8 +361,8 @@ export function DownloadRow({
</div>
<div className="text-xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
{job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
{job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
</div>
</>
)}
@@ -373,7 +374,8 @@ export function DownloadRow({
}
export function UsageBar() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
@@ -391,8 +393,8 @@ export function UsageBar() {
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{formatBytes(u.footprint_bytes)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}