From a5bcd9fca2674ab8b40b1c0a1ef09248c2678917 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 01:05:53 +0200 Subject: [PATCH] fix: numbers follow the UI language, not the browser's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../ChannelStatsPopover.dom.test.tsx | 4 +- .../src/components/ChannelStatsPopover.tsx | 6 +-- frontend/src/components/ReleaseNotes.tsx | 15 +++--- frontend/src/components/Scheduler.tsx | 24 ++++----- frontend/src/components/Stats.tsx | 24 ++++----- frontend/src/components/VideoCard.tsx | 4 +- frontend/src/components/channelColumns.tsx | 4 +- .../src/components/ui/ItemCount.dom.test.tsx | 32 +++++++++--- frontend/src/components/ui/ItemCount.tsx | 5 +- frontend/src/lib/format.test.ts | 51 ++++++++++++++++++- frontend/src/lib/format.ts | 23 ++++++++- 11 files changed, 141 insertions(+), 51 deletions(-) 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 (