fix: numbers follow the UI language, not the browser's

`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.

ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.

The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
This commit is contained in:
2026-09-16 01:05:53 +02:00
parent 618ef812e6
commit a5bcd9fca2
11 changed files with 141 additions and 51 deletions
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, formatNumber } from "../lib/format";
beforeAll(() => {
class Stub {
@@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => {
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic
});
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
@@ -63,13 +63,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{
icon: Film,
label: t("channels.stats.myVideos"),
value: status.my_videos.toLocaleString(),
value: formatNumber(status.my_videos),
hint: t("channels.stats.myVideosHint"),
},
{
icon: Zap,
label: t("channels.stats.quotaLeft"),
value: status.quota_remaining_today.toLocaleString(),
value: formatNumber(status.quota_remaining_today),
hint: t("channels.stats.quotaLeftHint"),
},
];
+8 -7
View File
@@ -1,13 +1,14 @@
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
import { formatDate } from "../lib/format";
import Modal from "./ui/Modal";
function fmtDate(d: string): string {
const t = new Date(d);
return isNaN(t.getTime())
? d
: t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
// back to the raw string (a hand-written note could carry anything).
function fmtDate(d: string, lang: string): string {
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
}
// Public release notes (chores are intentionally omitted here). `highlight` rings the
@@ -19,7 +20,7 @@ export default function ReleaseNotes({
onClose: () => void;
highlight?: string;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
// so the banner always rings *something* current instead of nothing.
@@ -36,7 +37,7 @@ export default function ReleaseNotes({
>
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date)}</span>
<span className="text-xs text-muted">{fmtDate(r.date, i18n.language)}</span>
{r.sha && (
<span className="text-2xs font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
{r.sha}
+12 -12
View File
@@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format";
import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
@@ -103,9 +103,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{p?.total != null
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
: p
? p.current.toLocaleString()
? formatNumber(p.current)
: ""}
</span>
</div>
@@ -291,7 +291,7 @@ function MaintenanceSettings({
</div>
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
default: m.revalidate_batch_default.toLocaleString(),
default: formatNumber(m.revalidate_batch_default),
})}
</div>
</div>
@@ -540,31 +540,31 @@ export default function Scheduler() {
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
value={data.queue.channels_recent_pending.toLocaleString()}
value={formatNumber(data.queue.channels_recent_pending)}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={data.queue.channels_deep_pending.toLocaleString()}
value={formatNumber(data.queue.channels_deep_pending)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={data.queue.videos_pending_enrich.toLocaleString()}
value={formatNumber(data.queue.videos_pending_enrich)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={data.queue.videos_pending_shorts.toLocaleString()}
value={formatNumber(data.queue.videos_pending_shorts)}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={data.queue.videos_live_refresh.toLocaleString()}
value={formatNumber(data.queue.videos_live_refresh)}
/>
</div>
</div>
@@ -579,15 +579,15 @@ export default function Scheduler() {
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{t("scheduler.quotaUsed", {
used: data.quota.used_today.toLocaleString(),
budget: data.quota.daily_budget.toLocaleString(),
used: formatNumber(data.quota.used_today),
budget: formatNumber(data.quota.daily_budget),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: data.quota.remaining_today.toLocaleString(),
formatted: formatNumber(data.quota.remaining_today),
})}
</span>
</div>
+12 -12
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./WizardProvider";
@@ -115,10 +115,10 @@ function Overview({ me }: { me: Me }) {
</SettingRow>
)}
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
{formatNumber(s.my_videos)}
</SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()}
{formatNumber(s.quota_remaining_today)}
</SettingRow>
</div>
) : status.isError ? (
@@ -135,16 +135,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")}>
{usage.data.today.toLocaleString()}
{formatNumber(usage.data.today)}
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>
{usage.data.last_7d.toLocaleString()}
{formatNumber(usage.data.last_7d)}
</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>
{usage.data.last_30d.toLocaleString()}
{formatNumber(usage.data.last_30d)}
</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>
{usage.data.all_time.toLocaleString()}
{formatNumber(usage.data.all_time)}
</SettingRow>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
@@ -155,7 +155,7 @@ function Overview({ me }: { me: Me }) {
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
</div>
))}
</div>
@@ -274,7 +274,7 @@ function SystemStats() {
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
count: data.range_days,
units: grandTotal.toLocaleString(),
units: formatNumber(grandTotal),
})}
</div>
{data.daily.length === 0 ? (
@@ -285,7 +285,7 @@ function SystemStats() {
<div
key={d.day}
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
title={t("stats.dailyTooltip", { day: d.day, units: d.total.toLocaleString() })}
title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })}
>
<div
className="w-full bg-accent hover:opacity-80 rounded-t transition"
@@ -328,7 +328,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">
{row.total.toLocaleString()}
{formatNumber(row.total)}
</span>
</button>
{/* Proportion bar */}
@@ -343,7 +343,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
</div>
))}
</div>
+2 -2
View File
@@ -22,7 +22,7 @@ import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
@@ -423,7 +423,7 @@ function VideoCard({
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
<span title={`${formatNumber(video.view_count)} ${t("card.views")}`}>
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
</span>
+2 -2
View File
@@ -3,7 +3,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import { formatCountOrDash, formatNumber, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./ui/DataTable";
import Tooltip from "./ui/Tooltip";
import { PriorityStepper } from "./ui/PriorityStepper";
@@ -66,7 +66,7 @@ function StoredCell({ c }: { c: ManagedChannel }) {
/>
</Tooltip>
)}
{c.stored_videos.toLocaleString()}
{formatNumber(c.stored_videos)}
</span>
);
}
@@ -8,11 +8,15 @@ import { render, screen, cleanup } from "@testing-library/react";
import "../../i18n"; // side-effect: initialise the shared i18next instance
import i18n from "i18next";
import ItemCount from "./ItemCount";
import { formatNumber } from "../../lib/format";
beforeAll(async () => {
await i18n.changeLanguage("en");
});
afterEach(cleanup);
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en"); // a case that switches language must not leak it to the next
});
const text = () => screen.getByTestId("item-count").textContent;
@@ -24,17 +28,31 @@ describe("ItemCount", () => {
it("pluralises and thousands-separates a normal count", () => {
render(<ItemCount count={192045} />);
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
expect(text()).toBe(`${(192045).toLocaleString()} items`);
// Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
// so the test asserts ItemCount's formatting path rather than the CI locale.
expect(text()).toBe(`${formatNumber(192045)} items`);
});
// The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on
// an English browser showed "192,045" instead of "192 045". The title carries the pure number (no
// plural string), and both languages are asserted — comparing against the runtime's own default
// would pass under the old bug on a machine whose locale happens to match the UI language.
it("groups in the UI language, not the browser's", async () => {
const title = () => screen.getByTestId("item-count").getAttribute("title");
render(<ItemCount count={192045} />);
const asEnglish = title();
cleanup();
await i18n.changeLanguage("hu");
render(<ItemCount count={192045} />);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(192045));
expect(title()).toBe(new Intl.NumberFormat("hu").format(192045));
expect(title()).not.toBe(asEnglish);
});
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render(<ItemCount count={1_000_000} />);
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
(1_000_000).toLocaleString(),
);
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
+3 -2
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
import { formatNumber } from "../../lib/format";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
@@ -20,12 +21,12 @@ export default function ItemCount({ count }: { count?: number }) {
? ""
: t("common.itemCount", {
count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
});
return (
<span
data-testid="item-count"
title={count?.toLocaleString()}
title={count == null ? undefined : formatNumber(count)}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
+50 -1
View File
@@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
import {
RELATIVE_UNITS,
formatNumber,
formatRuntime,
formatTotalHours,
relativeTime,
relativeFromMs,
} from "./format";
import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json";
@@ -114,6 +121,48 @@ describe("relativeFromMs", () => {
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
// A bare `n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a Hungarian browser
// printed "1 234 567" (and a Hungarian UI in an English browser "1,234,567"). The case below asserts
// BOTH languages and that the two differ, rather than comparing against the runtime's own default:
// the default is whatever locale the machine running the suite has (Hungarian on the dev box, English
// on a CI image), so a one-language assertion passes under the old bug on half the machines.
describe("formatNumber", () => {
afterEach(() => {
i18nStub.language = "en";
});
it("groups in the UI language, not in the runtime's", () => {
const asEnglish = formatNumber(1234567);
i18nStub.language = "hu";
const asHungarian = formatNumber(1234567);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
expect(asEnglish).not.toBe(asHungarian);
});
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
i18nStub.language = "hu";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
i18nStub.language = "en";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
});
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
expect(formatNumber(null)).toBe("");
expect(formatNumber(undefined)).toBe("");
expect(formatNumber(NaN)).toBe("");
expect(formatNumber(Infinity)).toBe("");
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
});
it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu";
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} h`);
});
});
describe("formatRuntime", () => {
const CASES: [number | null | undefined, string][] = [
[null, ""],
+22 -1
View File
@@ -132,7 +132,28 @@ export function formatEta(seconds: number): string {
export function formatTotalHours(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
return h >= 1 ? `${formatNumber(h)} h` : `${Math.max(1, Math.round(sec / 60))} m`;
}
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
const numberFormatters = new Map<string, Intl.NumberFormat>();
/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
* `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
* browser showed space-grouped figures and vice versa — the number half of the same defect
* `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
* `|| "—"` fallback covers it like a missing one. */
export function formatNumber(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "";
const lang = i18n.language;
let formatter = numberFormatters.get(lang);
if (!formatter) {
formatter = new Intl.NumberFormat(lang);
numberFormatters.set(lang, formatter);
}
return formatter.format(n);
}
export function formatViews(n: number | null): string {