diff --git a/frontend/src/components/ChannelStatsPopover.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
index f94f6ebb..b477c91f 100644
--- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx
+++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
@@ -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", () => {
diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx
index 3d20d933..7b0b9b8e 100644
--- a/frontend/src/components/ChannelStatsPopover.tsx
+++ b/frontend/src/components/ChannelStatsPopover.tsx
@@ -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"),
},
];
diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx
index 70e0631f..e0dc3120 100644
--- a/frontend/src/components/ReleaseNotes.tsx
+++ b/frontend/src/components/ReleaseNotes.tsx
@@ -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({
>
v{r.version}
- {fmtDate(r.date)}
+ {fmtDate(r.date, i18n.language)}
{r.sha && (
{r.sha}
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index 5f0e62be..a586bd95 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -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"] }) {
{phase}
{p?.total != null
- ? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
+ ? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
: p
- ? p.current.toLocaleString()
+ ? formatNumber(p.current)
: ""}
@@ -291,7 +291,7 @@ function MaintenanceSettings({
{t("scheduler.maintenance.batchNote", {
- default: m.revalidate_batch_default.toLocaleString(),
+ default: formatNumber(m.revalidate_batch_default),
})}
@@ -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)}
/>
@@ -579,15 +579,15 @@ export default function Scheduler() {
{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),
})}
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
- formatted: data.quota.remaining_today.toLocaleString(),
+ formatted: formatNumber(data.quota.remaining_today),
})}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx
index 21513e9d..db502a3a 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -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 }) {
)}
- {s.my_videos.toLocaleString()}
+ {formatNumber(s.my_videos)}
- {s.quota_remaining_today.toLocaleString()}
+ {formatNumber(s.quota_remaining_today)}
) : status.isError ? (
@@ -135,16 +135,16 @@ function Overview({ me }: { me: Me }) {
<>
- {usage.data.today.toLocaleString()}
+ {formatNumber(usage.data.today)}
- {usage.data.last_7d.toLocaleString()}
+ {formatNumber(usage.data.last_7d)}
- {usage.data.last_30d.toLocaleString()}
+ {formatNumber(usage.data.last_30d)}
- {usage.data.all_time.toLocaleString()}
+ {formatNumber(usage.data.all_time)}
{Object.keys(usage.data.by_action).length > 0 && (
@@ -155,7 +155,7 @@ function Overview({ me }: { me: Me }) {
.map(([action, units]) => (
{quotaActionLabel(action)}
- {units.toLocaleString()}
+ {formatNumber(units)}
))}
@@ -274,7 +274,7 @@ function SystemStats() {
{t("stats.dailyTotal", {
count: data.range_days,
- units: grandTotal.toLocaleString(),
+ units: formatNumber(grandTotal),
})}
{data.daily.length === 0 ? (
@@ -285,7 +285,7 @@ function SystemStats() {
- {row.total.toLocaleString()}
+ {formatNumber(row.total)}
{/* Proportion bar */}
@@ -343,7 +343,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{actions.map(([action, units]) => (
{quotaActionLabel(action)}
- {units.toLocaleString()}
+ {formatNumber(units)}
))}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index b24f3d89..12bd40b2 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -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 && (
-
+
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx
index b8b831db..f8b3b0dc 100644
--- a/frontend/src/components/channelColumns.tsx
+++ b/frontend/src/components/channelColumns.tsx
@@ -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 }) {
/>
)}
- {c.stored_videos.toLocaleString()}
+ {formatNumber(c.stored_videos)}
);
}
diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx
index 5295edb4..8fd0c962 100644
--- a/frontend/src/components/ui/ItemCount.dom.test.tsx
+++ b/frontend/src/components/ui/ItemCount.dom.test.tsx
@@ -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(
);
- // 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(
);
+ const asEnglish = title();
+ cleanup();
+ await i18n.changeLanguage("hu");
+ render(
);
+ 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(
);
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", () => {
diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx
index 58f07710..f185ce5d 100644
--- a/frontend/src/components/ui/ItemCount.tsx
+++ b/frontend/src/components/ui/ItemCount.tsx
@@ -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 (
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts
index e9d8dcb4..ab28ce42 100644
--- a/frontend/src/lib/format.test.ts
+++ b/frontend/src/lib/format.test.ts
@@ -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, ""],
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 16e7bf9d..0dde20e3 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -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();
+
+/** 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 {