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:
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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, ""],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user