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 ChannelStatsPopover from "./ChannelStatsPopover";
|
||||||
import { type MyStatus } from "../lib/api";
|
import { type MyStatus } from "../lib/api";
|
||||||
import { formatEta } from "../lib/format";
|
import { formatEta, formatNumber } from "../lib/format";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => {
|
|||||||
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
||||||
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
||||||
expect(screen.getByText("1/4")).toBeTruthy(); // full-history 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", () => {
|
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";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type MyStatus } from "../lib/api";
|
import { type MyStatus } from "../lib/api";
|
||||||
import { formatEta } from "../lib/format";
|
import { formatEta, formatNumber } from "../lib/format";
|
||||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||||
|
|
||||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
// 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,
|
icon: Film,
|
||||||
label: t("channels.stats.myVideos"),
|
label: t("channels.stats.myVideos"),
|
||||||
value: status.my_videos.toLocaleString(),
|
value: formatNumber(status.my_videos),
|
||||||
hint: t("channels.stats.myVideosHint"),
|
hint: t("channels.stats.myVideosHint"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
label: t("channels.stats.quotaLeft"),
|
label: t("channels.stats.quotaLeft"),
|
||||||
value: status.quota_remaining_today.toLocaleString(),
|
value: formatNumber(status.quota_remaining_today),
|
||||||
hint: t("channels.stats.quotaLeftHint"),
|
hint: t("channels.stats.quotaLeftHint"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Bug, Sparkles } from "lucide-react";
|
import { Bug, Sparkles } from "lucide-react";
|
||||||
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
||||||
|
import { formatDate } from "../lib/format";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
|
|
||||||
function fmtDate(d: string): string {
|
// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
|
||||||
const t = new Date(d);
|
// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
|
||||||
return isNaN(t.getTime())
|
// back to the raw string (a hand-written note could carry anything).
|
||||||
? d
|
function fmtDate(d: string, lang: string): string {
|
||||||
: t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public release notes (chores are intentionally omitted here). `highlight` rings the
|
// Public release notes (chores are intentionally omitted here). `highlight` rings the
|
||||||
@@ -19,7 +20,7 @@ export default function ReleaseNotes({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
highlight?: string;
|
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
|
// 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 —
|
// 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.
|
// 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">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<h3 className="text-base font-semibold">v{r.version}</h3>
|
<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 && (
|
{r.sha && (
|
||||||
<span className="text-2xs font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
|
<span className="text-2xs font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
|
||||||
{r.sha}
|
{r.sha}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { relativeTime } from "../lib/format";
|
import { formatNumber, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
@@ -103,9 +103,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
|
|||||||
<span className="truncate">{phase}</span>
|
<span className="truncate">{phase}</span>
|
||||||
<span className="tabular-nums shrink-0">
|
<span className="tabular-nums shrink-0">
|
||||||
{p?.total != null
|
{p?.total != null
|
||||||
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
|
||||||
: p
|
: p
|
||||||
? p.current.toLocaleString()
|
? formatNumber(p.current)
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +291,7 @@ function MaintenanceSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-2xs text-muted mt-1.5">
|
<div className="text-2xs text-muted mt-1.5">
|
||||||
{t("scheduler.maintenance.batchNote", {
|
{t("scheduler.maintenance.batchNote", {
|
||||||
default: m.revalidate_batch_default.toLocaleString(),
|
default: formatNumber(m.revalidate_batch_default),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,31 +540,31 @@ export default function Scheduler() {
|
|||||||
icon={Database}
|
icon={Database}
|
||||||
label={t("scheduler.queue.recentPending")}
|
label={t("scheduler.queue.recentPending")}
|
||||||
hint={t("scheduler.queue.recentPendingHint")}
|
hint={t("scheduler.queue.recentPendingHint")}
|
||||||
value={data.queue.channels_recent_pending.toLocaleString()}
|
value={formatNumber(data.queue.channels_recent_pending)}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
icon={Database}
|
icon={Database}
|
||||||
label={t("scheduler.queue.deepPending")}
|
label={t("scheduler.queue.deepPending")}
|
||||||
hint={t("scheduler.queue.deepPendingHint")}
|
hint={t("scheduler.queue.deepPendingHint")}
|
||||||
value={data.queue.channels_deep_pending.toLocaleString()}
|
value={formatNumber(data.queue.channels_deep_pending)}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label={t("scheduler.queue.enrichPending")}
|
label={t("scheduler.queue.enrichPending")}
|
||||||
hint={t("scheduler.queue.enrichPendingHint")}
|
hint={t("scheduler.queue.enrichPendingHint")}
|
||||||
value={data.queue.videos_pending_enrich.toLocaleString()}
|
value={formatNumber(data.queue.videos_pending_enrich)}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label={t("scheduler.queue.shortsPending")}
|
label={t("scheduler.queue.shortsPending")}
|
||||||
hint={t("scheduler.queue.shortsPendingHint")}
|
hint={t("scheduler.queue.shortsPendingHint")}
|
||||||
value={data.queue.videos_pending_shorts.toLocaleString()}
|
value={formatNumber(data.queue.videos_pending_shorts)}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
icon={RadioTower}
|
icon={RadioTower}
|
||||||
label={t("scheduler.queue.liveRefresh")}
|
label={t("scheduler.queue.liveRefresh")}
|
||||||
hint={t("scheduler.queue.liveRefreshHint")}
|
hint={t("scheduler.queue.liveRefreshHint")}
|
||||||
value={data.queue.videos_live_refresh.toLocaleString()}
|
value={formatNumber(data.queue.videos_live_refresh)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,15 +579,15 @@ export default function Scheduler() {
|
|||||||
<Gauge className="w-4 h-4 text-muted shrink-0" />
|
<Gauge className="w-4 h-4 text-muted shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{t("scheduler.quotaUsed", {
|
{t("scheduler.quotaUsed", {
|
||||||
used: data.quota.used_today.toLocaleString(),
|
used: formatNumber(data.quota.used_today),
|
||||||
budget: data.quota.daily_budget.toLocaleString(),
|
budget: formatNumber(data.quota.daily_budget),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<span className="text-muted text-xs">
|
<span className="text-muted text-xs">
|
||||||
{t("scheduler.quotaRemaining", {
|
{t("scheduler.quotaRemaining", {
|
||||||
count: data.quota.remaining_today,
|
count: data.quota.remaining_today,
|
||||||
formatted: data.quota.remaining_today.toLocaleString(),
|
formatted: formatNumber(data.quota.remaining_today),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
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 { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
@@ -115,10 +115,10 @@ function Overview({ me }: { me: Me }) {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
)}
|
)}
|
||||||
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||||
{s.my_videos.toLocaleString()}
|
{formatNumber(s.my_videos)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||||
{s.quota_remaining_today.toLocaleString()}
|
{formatNumber(s.quota_remaining_today)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
) : status.isError ? (
|
) : status.isError ? (
|
||||||
@@ -135,16 +135,16 @@ function Overview({ me }: { me: Me }) {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||||
{usage.data.today.toLocaleString()}
|
{formatNumber(usage.data.today)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label={t("stats.sync.last7d")}>
|
<SettingRow label={t("stats.sync.last7d")}>
|
||||||
{usage.data.last_7d.toLocaleString()}
|
{formatNumber(usage.data.last_7d)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label={t("stats.sync.last30d")}>
|
<SettingRow label={t("stats.sync.last30d")}>
|
||||||
{usage.data.last_30d.toLocaleString()}
|
{formatNumber(usage.data.last_30d)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label={t("stats.sync.allTime")}>
|
<SettingRow label={t("stats.sync.allTime")}>
|
||||||
{usage.data.all_time.toLocaleString()}
|
{formatNumber(usage.data.all_time)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(usage.data.by_action).length > 0 && (
|
{Object.keys(usage.data.by_action).length > 0 && (
|
||||||
@@ -155,7 +155,7 @@ function Overview({ me }: { me: Me }) {
|
|||||||
.map(([action, units]) => (
|
.map(([action, units]) => (
|
||||||
<div key={action} className="flex justify-between gap-2">
|
<div key={action} className="flex justify-between gap-2">
|
||||||
<span>{quotaActionLabel(action)}</span>
|
<span>{quotaActionLabel(action)}</span>
|
||||||
<span className="tabular-nums">{units.toLocaleString()}</span>
|
<span className="tabular-nums">{formatNumber(units)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +274,7 @@ function SystemStats() {
|
|||||||
<div className="text-xs text-muted mb-2">
|
<div className="text-xs text-muted mb-2">
|
||||||
{t("stats.dailyTotal", {
|
{t("stats.dailyTotal", {
|
||||||
count: data.range_days,
|
count: data.range_days,
|
||||||
units: grandTotal.toLocaleString(),
|
units: formatNumber(grandTotal),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{data.daily.length === 0 ? (
|
{data.daily.length === 0 ? (
|
||||||
@@ -285,7 +285,7 @@ function SystemStats() {
|
|||||||
<div
|
<div
|
||||||
key={d.day}
|
key={d.day}
|
||||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
|
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
|
<div
|
||||||
className="w-full bg-accent hover:opacity-80 rounded-t transition"
|
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}
|
{isSystem ? t("stats.system") : row.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||||
{row.total.toLocaleString()}
|
{formatNumber(row.total)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Proportion bar */}
|
{/* Proportion bar */}
|
||||||
@@ -343,7 +343,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
|||||||
{actions.map(([action, units]) => (
|
{actions.map(([action, units]) => (
|
||||||
<div key={action} className="flex items-center justify-between gap-2">
|
<div key={action} className="flex items-center justify-between gap-2">
|
||||||
<span>{quotaActionLabel(action)}</span>
|
<span>{quotaActionLabel(action)}</span>
|
||||||
<span className="tabular-nums">{units.toLocaleString()}</span>
|
<span className="tabular-nums">{formatNumber(units)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { navItemAttrs } from "../lib/listNav";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video, VideoStatus } from "../lib/api";
|
import type { Video, VideoStatus } from "../lib/api";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
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 { isPlainLeftClick } from "../lib/nav";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ function VideoCard({
|
|||||||
// on every line and the tooltip carries the figure regardless.
|
// on every line and the tooltip carries the figure regardless.
|
||||||
const viewsCell = (withWord: boolean) =>
|
const viewsCell = (withWord: boolean) =>
|
||||||
video.view_count != null && (
|
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)}
|
{formatViews(video.view_count)}
|
||||||
{withWord && ` ${t("card.views")}`}
|
{withWord && ` ${t("card.views")}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { TFunction } from "i18next";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
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 type { Column } from "./ui/DataTable";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||||
@@ -66,7 +66,7 @@ function StoredCell({ c }: { c: ManagedChannel }) {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{c.stored_videos.toLocaleString()}
|
{formatNumber(c.stored_videos)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { render, screen, cleanup } from "@testing-library/react";
|
|||||||
import "../../i18n"; // side-effect: initialise the shared i18next instance
|
import "../../i18n"; // side-effect: initialise the shared i18next instance
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import ItemCount from "./ItemCount";
|
import ItemCount from "./ItemCount";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await i18n.changeLanguage("en");
|
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;
|
const text = () => screen.getByTestId("item-count").textContent;
|
||||||
|
|
||||||
@@ -24,17 +28,31 @@ describe("ItemCount", () => {
|
|||||||
|
|
||||||
it("pluralises and thousands-separates a normal count", () => {
|
it("pluralises and thousands-separates a normal count", () => {
|
||||||
render(<ItemCount count={192045} />);
|
render(<ItemCount count={192045} />);
|
||||||
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
|
// Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
|
||||||
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
|
// so the test asserts ItemCount's formatting path rather than the CI locale.
|
||||||
expect(text()).toBe(`${(192045).toLocaleString()} items`);
|
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", () => {
|
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
|
||||||
render(<ItemCount count={1_000_000} />);
|
render(<ItemCount count={1_000_000} />);
|
||||||
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
|
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
|
||||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
|
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
|
||||||
(1_000_000).toLocaleString(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
|
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layers } from "lucide-react";
|
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
|
// 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.
|
// 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", {
|
: t("common.itemCount", {
|
||||||
count,
|
count,
|
||||||
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
|
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid="item-count"
|
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"
|
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" />
|
<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 { 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 en from "../i18n/locales/en/time.json";
|
||||||
import hu from "../i18n/locales/hu/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
|
// 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
|
// 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.
|
// 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", () => {
|
describe("formatRuntime", () => {
|
||||||
const CASES: [number | null | undefined, string][] = [
|
const CASES: [number | null | undefined, string][] = [
|
||||||
[null, ""],
|
[null, ""],
|
||||||
|
|||||||
@@ -132,7 +132,28 @@ export function formatEta(seconds: number): string {
|
|||||||
export function formatTotalHours(sec: number): string {
|
export function formatTotalHours(sec: number): string {
|
||||||
if (!sec) return "—";
|
if (!sec) return "—";
|
||||||
const h = Math.round(sec / 3600);
|
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 {
|
export function formatViews(n: number | null): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user