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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user