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