From aac79ffa531b48a2f837cf9d8c15dc945f81440a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 22:39:57 +0200 Subject: [PATCH] fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3) Every locale formatter takes the language as an argument. Under the React Compiler a formatter that read the i18n singleton was memoized on its other arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang() had the same flaw and left Plex queries in the old language. Components now take the language from useTranslation, and the signature makes forgetting it a type error. - ratings, file sizes, stream stats: the language's decimal mark ("8,3") - view and subscriber counts compact in the language ("1,2 E") - seconds as the language's unit ("6 mp") in the sliders and the stats - a person's birth and death dates as dates, not ISO strings - A-Z lists by the shown name in the language's alphabet (compareText), the tag catalog re-sorted by its translated names - lint local/locale-format (was date-format) also refuses Intl.NumberFormat outside lib/format.ts, localeCompare, and a component reading the language off the i18n singleton --- docs/DESIGN.md | 9 +- frontend/eslint.config.js | 63 +++++-- frontend/src/components/About.tsx | 9 +- frontend/src/components/AccountPanel.tsx | 5 +- frontend/src/components/AdminLogs.tsx | 10 +- .../src/components/AdminUsers.dom.test.tsx | 2 +- frontend/src/components/AdminUsers.tsx | 22 ++- frontend/src/components/AuditLog.tsx | 12 +- frontend/src/components/BootGate.tsx | 1 + frontend/src/components/ChannelDiscovery.tsx | 9 +- frontend/src/components/ChannelPage.tsx | 7 +- .../ChannelStatsPopover.dom.test.tsx | 12 +- .../src/components/ChannelStatsPopover.tsx | 9 +- frontend/src/components/Channels.tsx | 6 +- frontend/src/components/DebugRecorder.tsx | 5 +- .../src/components/DiagnosticsSettings.tsx | 7 +- .../DownloadCenter.confirm.dom.test.tsx | 5 +- .../DownloadCenter.menu.dom.test.tsx | 5 +- frontend/src/components/Feed.tsx | 4 +- frontend/src/components/Home.dom.test.tsx | 4 +- frontend/src/components/Home.tsx | 23 ++- frontend/src/components/IssueComposer.tsx | 10 +- .../components/Messages.keyboard.dom.test.tsx | 5 +- .../NotificationsPanel.menu.dom.test.tsx | 2 +- .../src/components/NotificationsPanel.tsx | 3 +- .../PersonalViewsModal.dom.test.tsx | 1 + frontend/src/components/PlayerModal.tsx | 11 +- frontend/src/components/Playlists.tsx | 9 +- frontend/src/components/PlaylistsRail.tsx | 10 +- frontend/src/components/PlexBrowse.tsx | 6 +- .../PlexCollectionView.dom.test.tsx | 7 +- .../src/components/PlexCollectionView.tsx | 8 +- .../src/components/PlexDiscover.dom.test.tsx | 1 + frontend/src/components/PlexDiscover.tsx | 4 +- frontend/src/components/PlexInfo.dom.test.tsx | 1 + frontend/src/components/PlexInfo.tsx | 54 +++--- .../components/PlexPersonView.dom.test.tsx | 10 +- frontend/src/components/PlexPlayer.tsx | 6 +- .../PlexPlayerSettings.dom.test.tsx | 2 +- .../src/components/PlexPlayerSettings.tsx | 10 +- .../PlexPlaylistView.menu.dom.test.tsx | 17 +- frontend/src/components/PlexPlaylistView.tsx | 5 +- frontend/src/components/PlexSidebar.tsx | 4 +- frontend/src/components/PlexStatsOverlay.tsx | 5 +- .../src/components/Scheduler.dom.test.tsx | 2 +- .../components/Scheduler.menu.dom.test.tsx | 2 +- frontend/src/components/Scheduler.tsx | 54 +++--- frontend/src/components/SettingsPanel.tsx | 21 ++- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/components/Stats.tsx | 44 ++--- frontend/src/components/SyncStatus.tsx | 16 +- .../src/components/UpNextCard.dom.test.tsx | 5 +- frontend/src/components/UpNextCard.tsx | 4 +- .../src/components/UploadDialog.dom.test.tsx | 5 +- frontend/src/components/UploadDialog.tsx | 7 +- frontend/src/components/VideoCard.tsx | 15 +- frontend/src/components/VideoInfoCard.tsx | 5 +- frontend/src/components/WatchPage.tsx | 2 +- frontend/src/components/channelCells.tsx | 5 +- .../components/channelColumns.dom.test.tsx | 1 + frontend/src/components/channelColumns.tsx | 14 +- .../comfortPin.langSwitch.dom.test.tsx | 34 ++++ frontend/src/components/comfortPin.tsx | 6 +- frontend/src/components/downloadAdmin.tsx | 11 +- .../src/components/downloadRow.dom.test.tsx | 5 +- frontend/src/components/downloadRow.tsx | 20 ++- frontend/src/components/plexBrowseLists.tsx | 8 +- .../src/components/plexCards.dom.test.tsx | 5 +- .../components/plexCards.lang.dom.test.tsx | 2 +- .../plexCards.langSwitch.dom.test.tsx | 35 ++++ frontend/src/components/plexCards.tsx | 14 +- .../src/components/plexPlayerControls.tsx | 16 +- .../components/plexViews.restart.dom.test.tsx | 5 +- frontend/src/components/plexViews.tsx | 24 +-- .../ui/DataTable.fixedLayout.dom.test.tsx | 5 +- .../ui/DataTable.keyboard.dom.test.tsx | 5 +- frontend/src/components/ui/DataTable.tsx | 5 +- .../ui/HiddenFilterCue.dom.test.tsx | 1 + .../src/components/ui/ItemCount.dom.test.tsx | 6 +- frontend/src/components/ui/ItemCount.tsx | 7 +- .../components/ui/RelativeTime.dom.test.tsx | 7 +- frontend/src/components/ui/RelativeTime.tsx | 6 +- .../components/useChannelActions.dom.test.tsx | 5 +- frontend/src/components/useChannelActions.tsx | 6 +- .../src/components/usePlexPlaylistEdits.ts | 4 +- frontend/src/i18n/index.ts | 12 +- frontend/src/lib/api/core.ts | 8 +- frontend/src/lib/collate.ts | 13 ++ frontend/src/lib/columnSort.test.ts | 28 +++- frontend/src/lib/columnSort.ts | 11 +- frontend/src/lib/feedSort.ts | 10 +- frontend/src/lib/feedSort.yt.test.ts | 48 ++++-- frontend/src/lib/format.test.ts | 99 +++++++---- frontend/src/lib/format.ts | 155 +++++++++++------- frontend/src/lib/jobResult.dom.test.ts | 20 ++- frontend/src/lib/jobResult.ts | 6 +- .../src/lib/localeFormat.contract.test.ts | 53 +++++- frontend/src/lib/playlistView.test.ts | 20 +-- frontend/src/lib/playlistView.ts | 6 +- frontend/src/lib/plexPlayerHooks.ts | 6 +- frontend/src/lib/plexSort.ts | 13 +- frontend/src/lib/plexStats.test.ts | 36 ++-- frontend/src/lib/plexStats.ts | 32 ++-- frontend/src/lib/useTags.dom.test.tsx | 27 ++- frontend/src/lib/useTags.ts | 20 ++- 105 files changed, 1001 insertions(+), 494 deletions(-) create mode 100644 frontend/src/components/comfortPin.langSwitch.dom.test.tsx create mode 100644 frontend/src/components/plexCards.langSwitch.dom.test.tsx create mode 100644 frontend/src/lib/collate.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cc84ee8b..4177a5da 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -531,7 +531,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # - **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose. - **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian. -- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list +- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/locale-format`): recency in a list is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover; a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" / "2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder. @@ -540,6 +540,12 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h", "7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the unit is a word. A clock-style length on a picture stays "2:34:11". +- **Numbers** in the UI language, from `lib/format.ts` (same lint): the decimal mark and grouping are the + language's ("8.3" / "8,3", "1,234" / "1 234"); a big count is compact ("1.2K" / "1,2 E"); a size keeps + its symbol ("5.0 GB" / "5,0 GB"), as do a stream's (Mbps) and "%" / "px". Never `toFixed` on screen. +- **Order.** An A→Z list is in the UI language's alphabet ("Ö" after "O", "Cs" after "Cu" in Hungarian), + by the name the reader SEES: `compareText` (`lib/collate.ts`) on the client, the ICU collation of + `app/collation.py` on the server, so both sort alike. - A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object. ## Anti-patterns @@ -624,6 +630,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # | 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line | | 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture | | 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift | +| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) | | 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart | | 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint | | 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed | diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 17759e77..414099d7 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -82,33 +82,66 @@ const focusSafeDisabled = { }; }, }; -// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all -// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale -// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts -// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a -// number goes through formatNumber, which the same rule covers). -const dateFormat = { +// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the +// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare +// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a +// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's +// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused, +// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere. +// +// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton +// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read +// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to +// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes. +const localeFormat = { meta: { type: "problem", messages: { - date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.", + date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.", + order: + "Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.", + singleton: + "Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.", }, }, create(context) { const path = context.filename.split("\\").join("/"); - // lib/format.ts is where the forms live; a test computes its expectations; the Plex player's - // on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date. - if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {}; + if (/\.test\.tsx?$/.test(path)) return {}; + // lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts) + // is a clock face with its own 12/24h switch, not a date. + const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path); + const collates = /\/src\/lib\/collate\.ts$/.test(path); + const component = /\/src\/components\//.test(path); + const importedFromI18n = (node) => { + for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) { + const v = scope.set.get(node.name); + if (v) + return v.defs.some( + (d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value), + ); + } + return false; + }; return { "CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"( node, ) { - context.report({ node, messageId: "date" }); + if (!formats) context.report({ node, messageId: "date" }); }, - "MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"( + "MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"( node, ) { - context.report({ node, messageId: "date" }); + if (!formats) context.report({ node, messageId: "date" }); + }, + "MemberExpression[object.name='Intl'][property.name='Collator']"(node) { + if (!collates) context.report({ node, messageId: "order" }); + }, + "CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) { + context.report({ node, messageId: "order" }); + }, + "MemberExpression[property.name='language'][object.type='Identifier']"(node) { + if (component && importedFromI18n(node.object)) + context.report({ node, messageId: "singleton" }); }, }; }, @@ -382,7 +415,7 @@ const localRules = { "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled, "button-primitive": buttonPrimitive, - "date-format": dateFormat, + "locale-format": localeFormat, "input-primitive": inputPrimitive, "surface-role": surfaceRole, "literal-a11y-text": literalA11yText, @@ -477,7 +510,7 @@ export default tseslint.config( "local/jsx-comment-text": "error", "local/focus-safe-disabled": "error", "local/button-primitive": "error", - "local/date-format": "error", + "local/locale-format": "error", "local/input-primitive": "error", "local/surface-role": "error", "local/literal-a11y-text": "error", diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 853ebfad..e925985e 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -8,10 +8,10 @@ import { formatDateTime } from "../lib/format"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; -function fmtDate(d?: string | null): string { +function fmtDate(d: string | null | undefined, lang: string): string { if (!d) return "—"; const t = new Date(d); - return isNaN(t.getTime()) ? d : formatDateTime(d); + return isNaN(t.getTime()) ? d : formatDateTime(d, lang); } // About dialog: app + build + schema versions (frontend/backend/database), plus a @@ -23,7 +23,8 @@ export default function About({ onClose: () => void; onOpenReleaseNotes: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const { data } = useQuery({ queryKey: qk.version(), queryFn: api.version, @@ -40,7 +41,7 @@ export default function About({ data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…", ], [t("about.database"), data?.db_revision ?? "…"], - [t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE)], + [t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE, lang)], ]; return ( diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 5442f1ed..f6b61efe 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -55,7 +55,8 @@ export default function AccountPanel({ user: AdminUserRow; onClose: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); const [copied, setCopied] = useState(false); @@ -182,7 +183,7 @@ export default function AccountPanel({ label={t("users.account.lastLogin")} value={ a.last_login_at - ? formatDateTime(a.last_login_at) + ? formatDateTime(a.last_login_at, lang) : a.is_demo ? t("users.account.lastLoginDemo") : t("users.account.lastLoginNone") diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 4839de51..bfc0a8e0 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -74,7 +74,8 @@ function toQuery(state: LogsUrlState, q: string): LogQuery { } export default function AdminLogs() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [state, setState] = useState(() => logsParamsToState(new URLSearchParams(window.location.search)), @@ -406,7 +407,7 @@ export default function AdminLogs() { level={row.level} event={row.event ?? "—"} msg={row.msg} - title={formatDateTimeSec(row.ts)} + title={formatDateTimeSec(row.ts, lang)} open={open === row.id} onToggle={() => setOpen(open === row.id ? null : row.id)} > @@ -468,14 +469,15 @@ function RowDetail({ onRequest: (id: string) => void; onJob: (id: number) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const pivotCls = "font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid"; return (
{t("adminLogs.fields.when")}
-
{formatDateTimeSec(row.ts)}
+
{formatDateTimeSec(row.ts, lang)}
{t("adminLogs.fields.source")}
{row.source} · {row.logger} diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index 2a6fcd94..81c87530 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -93,7 +93,7 @@ describe("the last sign-in column (E22 S1)", () => { , ); - expect(await screen.findByText(relativeTime(at))).toBeTruthy(); + expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy(); expect(screen.getAllByText("—")).toHaveLength(2); }); }); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 300d3027..dc72735f 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -103,7 +103,8 @@ export default function AdminUsers() { } function UsersRoles({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); @@ -336,14 +337,14 @@ function UsersRoles({ me }: { me: Me }) { - {u.last_login_at ? relativeTime(u.last_login_at) : "—"} + {u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
@@ -440,7 +441,8 @@ function PendingAccessRowView({ onActivate: () => void; onResend: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -449,7 +451,7 @@ function PendingAccessRowView({ {row.created_at && ( {t("users.pendingAccess.signedUp", { - time: formatDateTime(row.created_at), + time: formatDateTime(row.created_at, lang), })} )} @@ -536,6 +538,7 @@ function InviteLangPicker({ function AdminInvites() { const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const me = useMe(); @@ -640,7 +643,7 @@ function AdminInvites() { : "settings.invites.inviteConfirm", { email: inv.email, - time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()), + time: formatDateTime(inv.invited_at ?? new Date(0).toISOString(), lang), }, )}

@@ -744,7 +747,7 @@ function AdminInvites() { hint={ i.invited_at ? t("settings.invites.invitedAt", { - time: formatDateTime(i.invited_at), + time: formatDateTime(i.invited_at, lang), }) : t("settings.invites.inviteHint") } @@ -931,7 +934,8 @@ function InviteRow({ onDelete: () => void; busy: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -939,7 +943,7 @@ function InviteRow({ {inv.requested_at && (
{t("settings.invites.requested", { - time: formatDateTime(inv.requested_at), + time: formatDateTime(inv.requested_at, lang), })}
)} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 855c1087..fda79334 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -13,12 +13,14 @@ import DataTable from "./ui/DataTable"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { auditColumns } from "./auditColumns"; import { notifyActionError } from "../lib/actionErrors"; +import { compareText } from "../lib/collate"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run // summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via // DataTable; the API returns the most recent window (capped) — the log stays small by design. export default function AuditLog() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); @@ -26,9 +28,11 @@ export default function AuditLog() { const rows = useMemo(() => data?.items ?? [], [data]); const actionOptions = useMemo(() => { - const present = Array.from(new Set(rows.map((r) => r.action))).sort(); - return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })); - }, [rows, t]); + // By what the reader sees — the named action, in their alphabet — not by the code. + return Array.from(new Set(rows.map((r) => r.action))) + .map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })) + .sort((a, b) => compareText(a.label, b.label, lang)); + }, [rows, t, lang]); const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]); const clear = useMutation({ diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx index 7b524cf9..9e7bc8f0 100644 --- a/frontend/src/components/BootGate.tsx +++ b/frontend/src/components/BootGate.tsx @@ -104,6 +104,7 @@ export default function BootGate({ children }: { children: ReactNode }) { const account = meRef.current; if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); const lang = account?.preferences?.language; + // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang); // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them. setReadyFor(identity); diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index ef651069..3e0896f8 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -41,7 +41,8 @@ export default function ChannelDiscovery({ onOpenWizard: () => void; onViewChannel: (id: string, name: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is @@ -148,7 +149,7 @@ export default function ChannelDiscovery({ hideInCard: true, render: (c) => , }, - subsColumn(t), + subsColumn(t, lang), { key: "videos", header: t("channels.discovery.cols.totalVideos"), @@ -157,7 +158,7 @@ export default function ChannelDiscovery({ sortable: true, sortValue: (c) => c.video_count ?? -1, render: (c) => ( - {formatCountOrDash(c.video_count)} + {formatCountOrDash(c.video_count, lang)} ), }, { @@ -186,7 +187,7 @@ export default function ChannelDiscovery({ // computed for cards — DataTable sorts and slices internally, so doing it in table mode too would // copy+sort the whole list every render for a result nothing reads. const isCards = layout === "cards"; - const sortedRows = isCards ? sortRows(rows, columns, sort) : rows; + const sortedRows = isCards ? sortRows(rows, columns, sort, lang) : rows; const { paged: pagedRows, pagerProps, diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 02a6f02c..a15c2ca3 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -35,7 +35,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyActionError } from "../lib/actionErrors"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import type { FeedView } from "../lib/feedView"; -import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format"; +import { channelYouTubeUrl, formatDate, formatCompact } from "../lib/format"; import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { Button } from "./ui/Button"; @@ -70,6 +70,7 @@ export default function ChannelPage({ onShowInFeed: () => void; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { closeChannel } = useNavigationActions(); const qc = useQueryClient(); const confirm = useConfirm(); @@ -281,7 +282,7 @@ export default function ChannelPage({ const metaParts = [ ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null, ch?.subscriber_count != null - ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) + ? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) }) : null, // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the // gap between the browsable videos and YouTube's raw count is honest rather than looking like @@ -292,7 +293,7 @@ export default function ChannelPage({ : t("channel.videoCount", { count: ch.video_count }) : null, ch?.total_view_count != null - ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) + ? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) }) : null, // Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful // once we actually hold some of the channel's videos. diff --git a/frontend/src/components/ChannelStatsPopover.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx index b477c91f..570f8743 100644 --- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx +++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx @@ -14,12 +14,16 @@ import { FloatingTree } from "@floating-ui/react"; // (i18n/index.ts calls i18n.use(initReactI18next) at import time). vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import ChannelStatsPopover from "./ChannelStatsPopover"; import { type MyStatus } from "../lib/api"; import { formatEta, formatNumber } from "../lib/format"; +import type { TFunction } from "i18next"; beforeAll(() => { class Stub { @@ -74,7 +78,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(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic + expect(screen.getByText(formatNumber(1234, "en"))).toBeTruthy(); // my_videos, locale-agnostic }); it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => { @@ -86,7 +90,9 @@ describe("ChannelStatsPopover", () => { // The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the // formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this // locale/impl-agnostic (expected === what the component renders). - expect(screen.getByText(formatEta(120))).toBeTruthy(); + expect( + screen.getByText(formatEta(((k: string) => k) as unknown as TFunction, 120)), + ).toBeTruthy(); expect(screen.queryByText("120")).toBeNull(); // never the raw seconds }); diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 85fda529..0a280ab2 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -22,7 +22,8 @@ import { SURFACE } from "./ui/surfaces"; // with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these // are channel-catalog figures). export default function ChannelStatsPopover({ status }: { status: MyStatus }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const [open, setOpen] = useState(false); const pop = useAnchoredPopover({ open, @@ -58,7 +59,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) { { icon: Clock, label: t("channels.stats.left"), - value: formatEta(status.deep_eta_seconds), + value: formatEta(t, status.deep_eta_seconds), hint: t("channels.stats.leftHint", { count: status.deep_pending_count }), }, ] @@ -66,13 +67,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) { { icon: Film, label: t("channels.stats.myVideos"), - value: formatNumber(status.my_videos), + value: formatNumber(status.my_videos, lang), hint: t("channels.stats.myVideosHint"), }, { icon: Zap, label: t("channels.stats.quotaLeft"), - value: formatNumber(status.quota_remaining_today), + value: formatNumber(status.quota_remaining_today, lang), hint: t("channels.stats.quotaLeftHint"), }, ]; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index e6859372..356af484 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -107,7 +107,8 @@ export default function Channels() { const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App @@ -364,6 +365,7 @@ export default function Channels() { const columns = buildChannelColumns({ t, + lang: i18n.language, prioOptions, userTags, bumpPriority, @@ -471,7 +473,7 @@ export default function Channels() { // Cards have no column headers, so apply the shared column filters (sync + any funnel set in table // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort. const sortedChannels = isCards - ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort) + ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang) : syncedChannels; const { paged: pagedCards, diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx index 195601d0..b74d9e24 100644 --- a/frontend/src/components/DebugRecorder.tsx +++ b/frontend/src/components/DebugRecorder.tsx @@ -372,7 +372,8 @@ const chipTone = (s: DebugSession): string => { /** The last sessions, newest first — reopening one opens its composer, with what was written. */ function SessionHistory() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions }); const items = q.data?.items.filter((s) => s.ended_at) ?? []; if (q.isError && !q.data) @@ -394,7 +395,7 @@ function SessionHistory() { key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" > - {formatDateTimeSec(s.started_at)} + {formatDateTimeSec(s.started_at, lang)} {s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)} diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx index 87bce22e..c6f57f69 100644 --- a/frontend/src/components/DiagnosticsSettings.tsx +++ b/frontend/src/components/DiagnosticsSettings.tsx @@ -40,7 +40,8 @@ function useRemaining(until: string | null): { h: number; m: number } | null { // the server keeps it and for how long, and that it lapses; only its button raises the level. // Returning to Alap needs no consent and happens at once. export default function DiagnosticsSettings() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const me = useMe(); const qc = useQueryClient(); const state = me.diagnostics; @@ -173,8 +174,8 @@ export default function DiagnosticsSettings() { level: levelName(current), time: remaining.h > 0 - ? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}` - : formatUnit(remaining.m, "minute"), + ? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}` + : formatUnit(remaining.m, "minute", lang), })} diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index bdfe7757..06ba4905 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index 7a6695ac..ae34950e 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index b2ebdf78..37a82723 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -136,7 +136,8 @@ export default function Feed({ // that are constant within one channel (subscribers, priority) — they'd be meaningless. channelScoped?: boolean; }) { - const { t } = useTranslation(); + const { t, i18n: ui } = useTranslation(); + const lang = ui.language; // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected). const { openWizard: onOpenWizard } = useWizard(); // Same list the panel's badge counts — see lib/useActiveFilters. @@ -439,6 +440,7 @@ export default function Feed({ withOverrides(ytLoaded), ytSort.key as SortKey, ytSort.dir, + lang, ); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytError = diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index e3e1a886..4501d837 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -31,12 +31,14 @@ beforeAll(() => { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); + // the real instance: these tests switch the language with setLanguage + const real = (await import("i18next")).default; return { ...actual, useTranslation: () => ({ t: (key: string, opts?: Record) => opts?.time ? `${key}:${opts.time}` : key, - i18n: { language: "en" }, + i18n: real, }), }; }); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 06534a5d..27c47b98 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -30,7 +30,13 @@ import { } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent"; -import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format"; +import { + formatCompact, + formatDecimal, + formatDuration, + formatRuntime, + relativeTime, +} from "../lib/format"; import { toggleWatchState } from "../lib/plexWatch"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; import { type ActionMenuItem } from "./ui/ActionMenu"; @@ -502,7 +508,8 @@ function Hero({ onReroll: () => void; rerolling: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and // the same whole action list on its "…", right-click and long-press — it was a pin row alone. const { menu, items, panels, plexAdd } = useHomeCardMenu({ @@ -533,12 +540,12 @@ function Hero({ const plex = card.plex; const video = card.video; const facts = plex - ? [plex.year, formatRuntime(card.duration_seconds)] + ? [plex.year, formatRuntime(card.duration_seconds, lang)] : video ? // The channel is the subtitle already. [ - video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`, - relativeTime(video.published_at), + video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`, + relativeTime(video.published_at, lang), ] : []; const factLine = facts.filter(Boolean).join(" · "); @@ -631,7 +638,7 @@ function Hero({ {factLine && ·} - {plex.rating.toFixed(1)} + {formatDecimal(plex.rating, lang)} )}

@@ -692,7 +699,7 @@ function readSavedQuestion(): SavedQuestion | null { } export default function Home() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const { setPage } = useNavigationActions(); const { setJumpTo } = usePlexActions(); @@ -722,7 +729,7 @@ export default function Home() { const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null); // A Plex card's title is in the reader's language (E30), so the answer is cached per language. - const lang = plexLang(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.discover(budget, lang), queryFn: () => api.discover(budget, 0, lang), diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx index aeb00228..09b3f1d0 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/components/IssueComposer.tsx @@ -114,6 +114,7 @@ function ComposerBody({ total: number; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const pageName = (page: string) => { const def = moduleDef(page as Page); @@ -304,8 +305,8 @@ function ComposerBody({

{t("recorder.composer.meta", { - start: formatDateTimeSec(started.toISOString()), - minutes: formatUnit(minutes, "minute"), + start: formatDateTimeSec(started.toISOString(), lang), + minutes: formatUnit(minutes, "minute", lang), level: t(`settings.diagnostics.levels.${session.level}`), })}

@@ -607,7 +608,8 @@ function Attachments({ onReview: (source: Source) => void; reviewed: MutableRefObject; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const shots = shotsQ.data?.items ?? []; // A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays. @@ -685,7 +687,7 @@ function Attachments({ />
{t("recorder.composer.screenshot", { n: i + 1 })} - {formatBytes(shot.size)} + {formatBytes(shot.size, lang)}
{!readOnly && ( { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); const conversation = (id: number, name: string) => ({ diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx index 363e3b74..131e4925 100644 --- a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx +++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx @@ -23,7 +23,7 @@ const h = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../lib/useLiveQuery", () => ({ diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 89532371..9249e979 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -507,6 +507,7 @@ function NotificationRow({ onOpenScheduler: () => void; onOpenDownloads: () => void; }) { + const lang = useTranslation().i18n.language; // The maintenance producer ships the affected videos in `data.videos`; list a few titles. const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) ? n.data!.videos @@ -535,7 +536,7 @@ function NotificationRow({ job, }); const result = d.result ?? parseSummary(d.summary); - body = result ? jobResultText(t, result) : d.summary || n.body; + body = result ? jobResultText(t, result, lang) : d.summary || n.body; } else if (isEmailUndelivered) { // Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name // the message that was lost, since that is what tells the operator what their users didn't get. diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx index 486f2bf6..9d08d7a9 100644 --- a/frontend/src/components/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, o?: { name?: string }) => key === "common.order.dragNamed" ? `${key}:${o?.name}` : key, }), diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 2422348c..b492edb2 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -44,7 +44,7 @@ import { channelYouTubeUrl, formatDate, formatDuration, - formatViews, + formatCompact, relativeTime, } from "../lib/format"; import { renderDescription } from "./descriptionLinks"; @@ -119,6 +119,7 @@ export default function PlayerModal({ // icon next to the name keeps the open-on-YouTube behaviour. }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { openChannel } = useNavigationActions(); const qc = useQueryClient(); const descFade = useScrollFade(); @@ -899,12 +900,12 @@ export default function PlayerModal({ ·{" "} {t("player.views", { count: active.view_count, - formattedCount: formatViews(active.view_count), + formattedCount: formatCompact(active.view_count, lang), })} )} - · {relativeTime(active.published_at)} + · {relativeTime(active.published_at, lang)} {active.published_at && ` · ${fullDate(active.published_at)}`} {active.duration_seconds != null && ( @@ -924,13 +925,13 @@ export default function PlayerModal({ ·{" "} {t("player.views", { count: detail.data.view_count, - formattedCount: formatViews(detail.data.view_count), + formattedCount: formatCompact(detail.data.view_count, lang), })} )} {detail.data?.published_at && ( - · {relativeTime(detail.data.published_at)} ·{" "} + · {relativeTime(detail.data.published_at, lang)} ·{" "} {fullDate(detail.data.published_at)} )} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d9f14932..e92c73f5 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -160,7 +160,8 @@ export default function Playlists() { // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the // list + its rail controls). This detail pane reacts to the selection. const { selectedId } = usePlaylists(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const plActions = usePlaylistActions(); const [renaming, setRenaming] = useState(false); @@ -391,8 +392,8 @@ export default function Playlists() { const filterActive = Object.values(filters).some(isActive); const shown = useMemo( - () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy), - [items, columns, filters, viewSort, groupBy], + () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang), + [items, columns, filters, viewSort, groupBy, lang], ); // Read by the drag announcements, which must report the CURRENT positions without making the // announcement closures a render dependency of DndContext. @@ -909,7 +910,7 @@ export default function Playlists() {
{t("playlists.itemCount", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`} {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} {(items.length > 1 || viewActive) && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 5810eab2..ccc49e7f 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { compareText } from "../lib/collate"; import { formatRuntime } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -59,7 +60,8 @@ export default function PlaylistsRail({ // box and the Playlists detail pane); this rail owns the list query + its own controls. const { search, selectedId } = usePlaylists(); const { setSelectedId } = usePlaylistsActions(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); const plSortOptions: SortOption[] = [ @@ -123,7 +125,7 @@ export default function PlaylistsRail({ if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; switch (plSort.key) { case "name": - return sign * plName(a).localeCompare(plName(b)); + return sign * compareText(plName(a), plName(b), lang); case "count": return sign * (a.item_count - b.item_count); case "duration": @@ -134,7 +136,7 @@ export default function PlaylistsRail({ return 0; } }); - }, [playlists, plSort, plName]); + }, [playlists, plSort, plName, lang]); const q = search.trim().toLowerCase(); const visiblePlaylists = useMemo( @@ -366,7 +368,7 @@ export default function PlaylistsRail({
{t("playlists.itemCount", { count: pl.item_count })} {pl.total_duration_seconds > 0 && - ` · ${formatRuntime(pl.total_duration_seconds)}`} + ` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index c2c2cc53..33f7fae7 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -93,7 +93,7 @@ const BACKABLE_VIEWS: readonly string[] = [ ]; export default function PlexBrowse() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the // search term (q) comes from the shared header box; the rail hands off a playlist to open via the @@ -103,7 +103,7 @@ export default function PlexBrowse() { const dq = useDebounced(q.trim(), 350); // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch. - const lang = plexLang(); + const lang = plexLang(i18n.language); // The language the grid query is allowed to run at — lags `lang` by one render on a switch so the // genre-clear effect (below) can settle first (see the effect for why). const [committedLang, setCommittedLang] = useState(lang); @@ -116,7 +116,7 @@ export default function PlexBrowse() { // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. const sortState = healPlexSort(sort, scope); - const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); // Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx index e760402d..c29005e7 100644 --- a/frontend/src/components/PlexCollectionView.dom.test.tsx +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -27,7 +27,12 @@ beforeAll(() => { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + // the real instance: these tests switch the language with setLanguage + const real = (await import("i18next")).default; + return { + ...actual, + useTranslation: () => ({ i18n: real, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 9e1676e1..f17891e6 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -67,7 +67,7 @@ export default function PlexCollectionView({ onRestartCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // One menu for the member grid (see ui/ContextMenu). @@ -84,7 +84,7 @@ export default function PlexCollectionView({ // ONE key for the read and every write into it: when the sort joined the key, the watched toggle's // optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches // exactly), so the click did nothing visible until a reload. - const lang = plexLang(); + const lang = plexLang(i18n.language); const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang); const q = useQuery({ queryKey: detailKey, @@ -118,7 +118,7 @@ export default function PlexCollectionView({ // what the add-panel searches and which sort keys are on offer (only a movie has a runtime). const scope = items.some((c) => c.type === "show") ? "show" : "movie"; const sortState = healPlexSort(sortPref, scope); - const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang); // Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so // Play-all expands it into its episodes (buildQueue). Either kind means there's something to play — // this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's @@ -264,7 +264,7 @@ export default function PlexCollectionView({ (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`), d?.content_rating, d && t("plex.browse.items", { count: d.child_count ?? items.length }), - formatRuntime(d?.total_runtime_s), + formatRuntime(d?.total_runtime_s, lang), ].filter(Boolean) as string[]; return ( diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 70f7e906..c874d782 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, opts?: Record) => opts?.title ? `${key}:${opts.title}` : key, }), diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index c59800c6..55a79075 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -108,9 +108,9 @@ export default function PlexDiscover({ onGenre: (genre: string) => void; onSeeAllRecent: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const lang = plexLang(); + const lang = plexLang(i18n.language); // The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the // query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same). const { filters } = usePlex(); diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 4328cbf9..8554e15e 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, opts?: Record) => opts?.name ? `${key}:${opts.name}` : key, }), diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 61f3f950..de959932 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -4,12 +4,11 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api"; -import { formatRuntime } from "../lib/format"; +import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { usePref } from "../lib/usePref"; -import i18n from "../i18n"; import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -38,24 +37,6 @@ import { regionName, vocab } from "../lib/vocab"; // The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render // on the page variant only; the overlay stays lean. -// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts); -// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays -// USD — TMDB budget/revenue figures are USD regardless of the viewer's language. -const compact = (n: number) => - new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n); -const money = (n: number) => - new Intl.NumberFormat(i18n.language, { - notation: "compact", - style: "currency", - currency: "USD", - // The minimum is load-bearing, not a default written out: given only a maximum, a compact - // format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M" - // on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one - // was green), and browsers split along the same line. Pin it rather than inherit the engine's. - minimumFractionDigits: 0, - maximumFractionDigits: 1, - }).format(n); - // One rating chip's number: a filter button when this is the score the backend's ratingMin column // tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component // declared during render is a NEW component type on every render — React unmounts and remounts the @@ -70,7 +51,8 @@ function Score({ filterable: boolean; onFilterRating?: (score: number) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const shown = formatDecimal(score, i18n.language); return filterable && onFilterRating ? ( onFilterRating(Math.floor(score))} @@ -78,12 +60,12 @@ function Score({ title={t("plex.info.filterRating", { n: Math.floor(score) })} > - {score.toFixed(1)} + {shown} ) : ( - {score.toFixed(1)} + {shown} ); } @@ -110,7 +92,7 @@ function RatingsRow({ * theme's dark gold would sit on the player's black (2.6:1, measured). */ onDark?: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const primary = imdbRating ?? audience ?? null; // the filterable score // The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1. const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${ @@ -147,7 +129,7 @@ function RatingsRow({ - TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} + TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""} )} @@ -171,7 +153,8 @@ function FactsPanel({ detail: PlexItemDetail; onFilter?: (patch: Partial) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const tmdb = detail.tmdb; const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; const rows: { key: string; label: string; node: ReactNode }[] = []; @@ -195,7 +178,7 @@ function FactsPanel({ rows.push({ key: "runtime", label: t("plex.info.facts.runtime"), - node: formatRuntime(detail.duration_seconds), + node: formatRuntime(detail.duration_seconds, lang), }); const cert = detail.content_rating; if (cert) @@ -209,9 +192,17 @@ function FactsPanel({ ), }); if (tmdb?.budget) - rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) }); + rows.push({ + key: "budget", + label: t("plex.info.facts.budget"), + node: formatMoney(tmdb.budget, lang), + }); if (tmdb?.revenue) - rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) }); + rows.push({ + key: "revenue", + label: t("plex.info.facts.revenue"), + node: formatMoney(tmdb.revenue, lang), + }); if (studios.length) rows.push({ key: "studios", @@ -372,7 +363,8 @@ export default function PlexInfo({ onPlayItem, library, }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs: @@ -601,7 +593,7 @@ export default function PlexInfo({ )} {detail.duration_seconds ? ( - {formatRuntime(detail.duration_seconds)} + {formatRuntime(detail.duration_seconds, lang)} ) : null} {detail.studio && ( { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + // the real instance: these tests switch the language with setLanguage + const real = (await import("i18next")).default; + return { + ...actual, + useTranslation: () => ({ i18n: real, t: (key: string) => key }), + }; }); const plexPerson = vi.fn(); @@ -116,7 +121,8 @@ describe("PlexPersonView", () => { // E30: TMDB's English department, named through the vocab (key-echo i18n here). expect(screen.getByText("vocab.department.Acting")).toBeTruthy(); expect(screen.getByText("A celebrated actor.")).toBeTruthy(); - expect(screen.getByText(/1970-01-01/)).toBeTruthy(); + // E30 S3: the birth date as a date in the UI language, not the raw ISO string + expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy(); expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy(); expect(screen.getByText(/Janie D/)).toBeTruthy(); // "Known for" rail renders its card. diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index ddb9612e..837d2563 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -145,7 +145,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } const qc = useQueryClient(); const [id, setId] = useState(itemId); // Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid. - const lang = plexLang(); + const lang = plexLang(i18n.language); const detailQ = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang), @@ -986,7 +986,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S); flashOsd( t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", { - d: formatUnit(skips * TAP_SKIP_S, "second"), + d: formatUnit(skips * TAP_SKIP_S, "second", lang), }), ); return; @@ -999,7 +999,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } } toggleControls(); }, - [togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible], + [togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible], ); // Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the diff --git a/frontend/src/components/PlexPlayerSettings.dom.test.tsx b/frontend/src/components/PlexPlayerSettings.dom.test.tsx index a1403f57..37289ca8 100644 --- a/frontend/src/components/PlexPlayerSettings.dom.test.tsx +++ b/frontend/src/components/PlexPlayerSettings.dom.test.tsx @@ -10,7 +10,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), - useTranslation: () => ({ t: (k: string) => k }), + useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }), })); import { PlexPlayerSettings } from "./PlexPlayerSettings"; diff --git a/frontend/src/components/PlexPlayerSettings.tsx b/frontend/src/components/PlexPlayerSettings.tsx index 82fc8a43..81047446 100644 --- a/frontend/src/components/PlexPlayerSettings.tsx +++ b/frontend/src/components/PlexPlayerSettings.tsx @@ -84,7 +84,7 @@ export function PlexPlayerSettings(props: { min={-10} max={10} step={0.1} - suffix="s" + seconds onChange={(v) => patchPrefs({ subOffset: v })} onReset={() => patchPrefs({ subOffset: 0 })} /> @@ -94,7 +94,7 @@ export function PlexPlayerSettings(props: { min={-5} max={5} step={0.1} - suffix="s" + seconds onChange={changeAudioOffset} onReset={() => changeAudioOffset(0)} /> @@ -109,7 +109,7 @@ export function PlexPlayerSettings(props: { min={5} max={30} step={1} - suffix="s" + seconds onChange={(v) => patchPrefs({ seekStep: v })} /> patchPrefs({ fineSeekStep: v })} /> patchPrefs({ autoSkipDelay: v })} /> )} diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx index 10057d3a..c6e22b46 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -9,13 +9,16 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -vi.mock("react-i18next", async (importOriginal) => ({ - ...(await importOriginal()), - useTranslation: () => ({ - t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), - i18n: { language: "en" }, - }), -})); +vi.mock("react-i18next", async (importOriginal) => { + const real = (await import("i18next")).default; + return { + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), + i18n: real, + }), + }; +}); const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index d4771e42..13ec004e 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -406,7 +406,8 @@ export default function PlexPlaylistView({ /** A title's media-info page, from its menu's "Details". */ onInfoCard?: (card: PlexCard) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const [renaming, setRenaming] = useState(false); @@ -755,7 +756,7 @@ export default function PlexPlaylistView({ {items.length > 0 && (
{t("plex.browse.items", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
)}
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 0a1630a2..fb3e83ef 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -44,7 +44,7 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[ ]; export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // the header search box); this rail is the primary editor of them. Opening a playlist is a // hand-off to PlexBrowse via the shared `playlistOpen` slot. @@ -60,7 +60,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling // never refetches. collectionMode stays IN the key: any/all changes the result set. const { collectionTitles: _cts, ...facetKey } = filters; - const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch + const lang = plexLang(i18n.language); // HU/EN genre vocabulary — refetch facets on a language switch const facetsQ = useQuery({ queryKey: qk.plexFacets(scope, show, facetKey, lang), queryFn: () => api.plexFacets(scope, filters, show, lang), diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 24e33974..369889a8 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -38,7 +38,8 @@ export function PlexStatsOverlay({ durationRef: RefObject; onClose: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const [rows, setRows] = useState([]); useEffect(() => { @@ -87,6 +88,7 @@ export function PlexStatsOverlay({ setRows( buildStatRows({ + lang, playable, hwEncoder: hwRef.current, tonemap: tonemapRef.current, @@ -110,6 +112,7 @@ export function PlexStatsOverlay({ const iv = setInterval(sample, 1000); return () => clearInterval(iv); }, [ + lang, videoRef, hlsRef, fragMeterRef, diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx index c7948f15..4ec5142c 100644 --- a/frontend/src/components/Scheduler.dom.test.tsx +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; + return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) }; }); import Scheduler from "./Scheduler"; diff --git a/frontend/src/components/Scheduler.menu.dom.test.tsx b/frontend/src/components/Scheduler.menu.dom.test.tsx index 08b6b6fa..cb8e0f26 100644 --- a/frontend/src/components/Scheduler.menu.dom.test.tsx +++ b/frontend/src/components/Scheduler.menu.dom.test.tsx @@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; + return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index f0249f85..2adeeecc 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import i18n from "../i18n"; +import type { TFunction } from "i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, @@ -47,14 +47,14 @@ function secsUntil(iso: string | null): number | null { return Math.round((new Date(iso).getTime() - Date.now()) / 1000); } -function fmtCountdown(s: number): string { - if (s <= 0) return i18n.t("time.eta.now"); +function fmtCountdown(t: TFunction, s: number, lang: string): string { + if (s <= 0) return t("time.eta.now"); // The countdown speaks the UI language's units (DESIGN Copy), like every other duration. - if (s < 60) return formatUnit(s, "second"); + if (s < 60) return formatUnit(s, "second", lang); const m = Math.floor(s / 60); - if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`; + if (m < 60) return `${formatUnit(m, "minute", lang)} ${formatUnit(s % 60, "second", lang)}`; const h = Math.floor(m / 60); - return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`; + return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`; } const DOT_CLASS: Record = { @@ -104,7 +104,8 @@ function StatusLegend() { // label and an indeterminate sliver — so every active run is visible, not just the ones // that report numbers. function JobProgress({ p }: { p: SchedulerJob["progress"] }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working"); const pct = p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null; @@ -114,9 +115,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) { {phase} {p?.total != null - ? `${formatNumber(p.current)} / ${formatNumber(p.total)}` + ? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}` : p - ? formatNumber(p.current) + ? formatNumber(p.current, lang) : ""}
@@ -152,7 +153,8 @@ function JobRow({ /** A run-all in flight: transient, natively disabled. */ runBusy: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // The box shows the server's value and holds a draft only while it is being edited, so there is // nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page // refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the @@ -247,17 +249,17 @@ function JobRow({
{job.running ? t("scheduler.runningNow") : job.last_finished - ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) : t("scheduler.neverRun")} {job.last_error ? ( · {job.last_error} ) : job.result ? ( - · {jobResultText(t, job.result)} + · {jobResultText(t, job.result, lang)} ) : job.last_result ? ( · {job.last_result} ) : null} @@ -270,7 +272,7 @@ function JobRow({ ) : next != null ? ( <>
{t("scheduler.nextRun")}
-
{fmtCountdown(next)}
+
{fmtCountdown(t, next, lang)}
) : ( "—" @@ -303,7 +305,8 @@ function MaintenanceSettings({ onSave: (batch: number) => void; saving: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Same shape as the job row above: a draft while editing, the server's value otherwise. This is // the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no // `editing` guard at all. @@ -363,7 +366,7 @@ function MaintenanceSettings({ )}
{t("scheduler.maintenance.batchNote", { - default: formatNumber(m.revalidate_batch_default), + default: formatNumber(m.revalidate_batch_default, lang), })}
@@ -406,7 +409,8 @@ function Stat({ } export default function Scheduler() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); // Poll faster while any job is running so live progress is smooth, then ease back when @@ -647,31 +651,31 @@ export default function Scheduler() { icon={Database} label={t("scheduler.queue.recentPending")} hint={t("scheduler.queue.recentPendingHint")} - value={formatNumber(data.queue.channels_recent_pending)} + value={formatNumber(data.queue.channels_recent_pending, lang)} />
@@ -684,15 +688,15 @@ export default function Scheduler() { {t("scheduler.quotaUsed", { - used: formatNumber(data.quota.used_today), - budget: formatNumber(data.quota.daily_budget), + used: formatNumber(data.quota.used_today, lang), + budget: formatNumber(data.quota.daily_budget, lang), })} {t("scheduler.quotaRemaining", { count: data.quota.remaining_today, - formatted: formatNumber(data.quota.remaining_today), + formatted: formatNumber(data.quota.remaining_today, lang), })} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c757bd6a..4fa8ee34 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { withActiveAccount } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; -import { formatDate, formatDateTime } from "../lib/format"; +import { formatDate, formatDateTime, formatUnit } from "../lib/format"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; @@ -245,7 +245,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) { } function Notifications({ prefs }: { prefs: PrefsController }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves // it. The "send test" button below fires immediately. const { notif: cfg, setNotif } = prefs; @@ -270,7 +271,9 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
{t("settings.notifications.dismissAfter")} - {(cfg.durationMs / 1000).toFixed(0)}s + + {formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)} +
api.plexWatchLink() }); const enabled = link.data?.sync_enabled ?? false; @@ -565,7 +569,9 @@ function PlexWatchSync() { // Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than // only flipping once the multi-second import returns — otherwise it reads as "nothing happened". const shown = toggle.isPending ? Boolean(toggle.variables) : enabled; - const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null; + const last = link.data?.last_watch_sync_at + ? formatDateTime(link.data.last_watch_sync_at, lang) + : null; return (
@@ -765,7 +771,8 @@ export function DownloadAuth() { /** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does * not recognise is something they can see. */ export function AccountHeader({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) { {!me.is_demo && (
{me.last_login_at - ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) }) + ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) }) : t("settings.account.lastLoginNone")}
)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 97dc86ea..1de2d9a6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "./channelsContext"; import { useMe } from "../lib/useMe"; import { useTags } from "../lib/useTags"; +import { compareText } from "../lib/collate"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -47,7 +48,8 @@ export default function Sidebar({ collapsed: boolean; onToggleCollapse: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const isDemo = useMe().is_demo; const { openModal } = useSavedViews(); const filters = useFeedFilters(); @@ -76,7 +78,9 @@ export default function Sidebar({ const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) : list; - return [...shown].sort((a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)); + return [...shown].sort( + (a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang), + ); }; const languages = tags.filter((t) => t.category === "language"); diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 9e9e4049..ab825c4d 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -66,7 +66,8 @@ export default function Stats() { // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. function Overview({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here. const { openWizard } = useWizard(); @@ -125,14 +126,14 @@ function Overview({ me }: { me: Me }) { label={t("stats.sync.fullHistoryEta")} hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })} > - {formatEta(s.deep_eta_seconds)} + {formatEta(t, s.deep_eta_seconds)} )} - {formatNumber(s.my_videos)} + {formatNumber(s.my_videos, lang)} - {formatNumber(s.quota_remaining_today)} + {formatNumber(s.quota_remaining_today, lang)}
) : status.isError ? ( @@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) { <>
- {formatNumber(usage.data.today)} + {formatNumber(usage.data.today, lang)} - {formatNumber(usage.data.last_7d)} + {formatNumber(usage.data.last_7d, lang)} - {formatNumber(usage.data.last_30d)} + {formatNumber(usage.data.last_30d, lang)} - {formatNumber(usage.data.all_time)} + {formatNumber(usage.data.all_time, lang)}
{Object.keys(usage.data.by_action).length > 0 && ( @@ -168,8 +169,8 @@ function Overview({ me }: { me: Me }) { .sort((a, b) => b[1] - a[1]) .map(([action, units]) => (
- {quotaActionLabel(action)} - {formatNumber(units)} + {quotaActionLabel(t, action)} + {formatNumber(units, lang)}
))}
@@ -218,7 +219,8 @@ function Overview({ me }: { me: Me }) { // Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle. function SystemStats() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [days, setDays] = useState(30); const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); @@ -263,7 +265,7 @@ function SystemStats() { className="flex items-center gap-1.5" options={RANGES.map((d) => ({ value: String(d), - label: t("stats.rangeDays", { d: formatUnit(d, "day") }), + label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }), }))} value={String(days)} onChange={(v) => setDays(Number(v))} @@ -290,8 +292,8 @@ function SystemStats() {
{t("stats.dailyTotal", { - d: formatUnit(data.range_days, "day"), - units: formatNumber(grandTotal), + d: formatUnit(data.range_days, "day", lang), + units: formatNumber(grandTotal, lang), })}
{data.daily.length === 0 ? ( @@ -304,7 +306,10 @@ function SystemStats() { // The day under the pointer is lit by the B3 layer over its whole column, not by // dimming the bar: an opacity dim reads as "disabled". className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer" - title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })} + title={t("stats.dailyTooltip", { + day: d.day, + units: formatNumber(d.total, lang), + })} >
b[1] - a[1]); const isSystem = row.user_id === null; @@ -349,7 +355,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { {isSystem ? t("stats.system") : row.email} - {formatNumber(row.total)} + {formatNumber(row.total, lang)} {/* Proportion bar */} @@ -363,8 +369,8 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{actions.map(([action, units]) => (
- {quotaActionLabel(action)} - {formatNumber(units)} + {quotaActionLabel(t, action)} + {formatNumber(units, lang)}
))}
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 0ddc4d4d..19c323e9 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { api, type MyStatus } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; -import { formatViews } from "../lib/format"; +import { formatCompact } from "../lib/format"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Button } from "./ui/Button"; @@ -23,7 +23,8 @@ export default function SyncStatus({ isAdmin: boolean; onGoToFullHistory: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [open, setOpen] = useState(false); // A read-out with a couple of controls rather than a list of choices, so it is a `dialog` @@ -114,8 +115,9 @@ export default function SyncStatus({ // popover. An accent dot flags the only two things worth interrupting you for (paused / missing // history) so the collapsed chip still tells you when to look. const needsAttention = data.paused || notFull > 0; - const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews( + const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact( data.total_videos, + lang, )} ${t("header.sync.total")}`; return (
@@ -132,7 +134,9 @@ export default function SyncStatus({ ) : ( )} - {formatViews(data.my_videos)} + + {formatCompact(data.my_videos, lang)} + {needsAttention && ( )} @@ -149,10 +153,10 @@ export default function SyncStatus({ > - {formatViews(data.my_videos)}{" "} + {formatCompact(data.my_videos, lang)}{" "} {t("header.sync.yours")} / - {formatViews(data.total_videos)} {t("header.sync.total")} + {formatCompact(data.total_videos, lang)} {t("header.sync.total")}
{showMain && ( diff --git a/frontend/src/components/UpNextCard.dom.test.tsx b/frontend/src/components/UpNextCard.dom.test.tsx index f7f6bab5..bea3686a 100644 --- a/frontend/src/components/UpNextCard.dom.test.tsx +++ b/frontend/src/components/UpNextCard.dom.test.tsx @@ -11,7 +11,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("../lib/api", () => ({ diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx index 5a72b342..92178c4a 100644 --- a/frontend/src/components/UpNextCard.tsx +++ b/frontend/src/components/UpNextCard.tsx @@ -23,8 +23,8 @@ export function UpNextCard({ onAdvance: () => void; onCancel: () => void; }) { - const { t } = useTranslation(); - const lang = plexLang(); + const { t, i18n } = useTranslation(); + const lang = plexLang(i18n.language); const nextQ = useQuery({ queryKey: qk.plexItem(nextId, lang), queryFn: () => api.plexItem(nextId, lang), diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index a02de9b8..61a085f7 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -19,7 +19,10 @@ const h = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage }, diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 009ac56f..22743bf3 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -21,7 +21,8 @@ import { Input } from "./ui/Input"; // file is streamed to the server via XHR so this dialog can show real upload progress. On success it // lands in the Library as an already-`done` job, so we invalidate + point the user there. export default function UploadDialog({ onClose }: { onClose: () => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [file, setFile] = useState(null); const [name, setName] = useState(""); @@ -148,7 +149,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) { <> {file.name} - {formatBytes(file.size)} + {formatBytes(file.size, lang)} ) : ( <> @@ -172,7 +173,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) { {tooBig && ( )} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index a5887197..c1e4f774 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -17,7 +17,13 @@ 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, formatNumber, formatViews, relativeTime } from "../lib/format"; +import { + formatDate, + formatDuration, + formatNumber, + formatCompact, + relativeTime, +} from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; import { endsTextSelection } from "../lib/textSelection"; import ChannelNameLink from "./ChannelNameLink"; @@ -391,6 +397,7 @@ function VideoCard({ shelf?: boolean; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { openChannel } = useNavigationActions(); const spec = FEED_VIEW_SPEC[view]; const watched = video.status === "watched"; @@ -405,14 +412,14 @@ function VideoCard({ // on every line and the tooltip carries the figure regardless. const viewsCell = (withWord: boolean) => video.view_count != null && ( - - {formatViews(video.view_count)} + + {formatCompact(video.view_count, lang)} {withWord && ` ${t("card.views")}`} ); const published = ( <> - {relativeTime(video.published_at)} + {relativeTime(video.published_at, lang)} {video.published_at && ( <> {" · "} diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index d0521759..ddc74a63 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type Video } from "../lib/api"; -import { formatDate, formatDuration, formatViews } from "../lib/format"; +import { formatDate, formatDuration, formatCompact } from "../lib/format"; import { linkify } from "../lib/linkify"; import Avatar from "./ui/Avatar"; import { PopoverSurface } from "./ui/AnchoredPopover"; @@ -34,6 +34,7 @@ export default function VideoDetailsAt({ onClose: () => void; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const pop = useAnchoredPopover({ open: true, onOpenChange: (next) => { @@ -72,7 +73,7 @@ export default function VideoDetailsAt({ const description = detail?.description?.trim(); const metaBits = [ - video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null, + video.view_count != null ? `${formatCompact(video.view_count, lang)} ${t("card.views")}` : null, video.duration_seconds != null ? formatDuration(video.duration_seconds) : null, video.published_at ? formatDate(video.published_at, i18n.language) : null, ].filter(Boolean); diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 3173d002..47e87f30 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -317,7 +317,7 @@ export default function WatchPage() { {meta.title || T.file}
- {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)] + {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)] .filter(Boolean) .join(" · ")}
diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index 88381aee..8e5dd932 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -12,7 +12,8 @@ import { isFullySynced } from "./channelSync"; // full history are both in — folding the retired Sync column's at-a-glance status into the value it // describes. Full detail stays in the hover tooltip. export function StoredCell({ c }: { c: ManagedChannel }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return ( {isFullySynced(c) && ( @@ -24,7 +25,7 @@ export function StoredCell({ c }: { c: ManagedChannel }) { /> )} - {formatNumber(c.stored_videos)} + {formatNumber(c.stored_videos, lang)} ); } diff --git a/frontend/src/components/channelColumns.dom.test.tsx b/frontend/src/components/channelColumns.dom.test.tsx index 7e96ad00..3128b403 100644 --- a/frontend/src/components/channelColumns.dom.test.tsx +++ b/frontend/src/components/channelColumns.dom.test.tsx @@ -78,6 +78,7 @@ describe("buildChannelColumns — the row's end", () => { const noop = () => {}; const cols = buildChannelColumns({ t: ((k: string) => k) as never, + lang: "en", prioOptions: [], userTags: [], bumpPriority: noop, diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index de5d3939..d394b8cd 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -14,7 +14,10 @@ import { RelativeTime } from "./ui/RelativeTime"; /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different * channel row shapes. */ -export function subsColumn(t: TFunction): Column { +export function subsColumn( + t: TFunction, + lang: string, +): Column { return { key: "subs", header: t("channels.cols.subs"), @@ -23,7 +26,7 @@ export function subsColumn(t: TFu sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( - {formatCountOrDash(c.subscriber_count)} + {formatCountOrDash(c.subscriber_count, lang)} ), }; } @@ -50,6 +53,8 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { * column block lives outside the page component (its cell components are in channelCells). */ export interface ChannelColumnDeps { t: TFunction; + /** The UI language, for the numbers — the compiler re-builds the columns when it changes. */ + lang: string; prioOptions: { value: string; label: string }[]; userTags: ComponentProps["userTags"]; bumpPriority: (id: string, delta: number) => void; @@ -69,6 +74,7 @@ export interface ChannelColumnDeps { export function buildChannelColumns(deps: ChannelColumnDeps): Column[] { const { t, + lang, prioOptions, userTags, bumpPriority, @@ -144,7 +150,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column , }, - { ...subsColumn(t), width: "6rem" }, + { ...subsColumn(t, lang), width: "6rem" }, { key: "lastUpload", header: t("channels.cols.lastUpload"), @@ -165,7 +171,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds, render: (c) => ( - {formatTotalHours(c.total_duration_seconds)} + {formatTotalHours(c.total_duration_seconds, lang)} ), }, diff --git a/frontend/src/components/comfortPin.langSwitch.dom.test.tsx b/frontend/src/components/comfortPin.langSwitch.dom.test.tsx new file mode 100644 index 00000000..e0641de8 --- /dev/null +++ b/frontend/src/components/comfortPin.langSwitch.dom.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +// +// E30 S3: a Plex query follows a language switch. `plexLang()` read the i18n singleton, and under the +// React Compiler a value with no reactive input is computed once: after switching to Hungarian the +// comfort shelf kept asking for its English cards. The language now comes off useTranslation. Real +// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test). +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { setLanguage } from "../i18n"; +import { api } from "../lib/api"; +import { useComfortPin } from "./comfortPin"; + +afterEach(() => { + vi.restoreAllMocks(); + setLanguage("en"); +}); + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe("the comfort shelf after a language switch", () => { + it("asks for its cards in the new language", async () => { + const get = vi.spyOn(api, "comfortPins").mockImplementation(() => new Promise(() => {})); + setLanguage("en"); + renderHook(() => useComfortPin(true), { wrapper }); + await waitFor(() => expect(get).toHaveBeenCalledWith("en")); + act(() => setLanguage("hu")); + await waitFor(() => expect(get).toHaveBeenCalledWith("hu")); + }); +}); diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx index 7ac84b9e..0c3663cc 100644 --- a/frontend/src/components/comfortPin.tsx +++ b/frontend/src/components/comfortPin.tsx @@ -95,7 +95,7 @@ export interface ComfortPin { * every feed load would be a real request for a string almost nobody reads that session. */ export function useComfortPin(open: boolean): ComfortPin { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides // WHEN the first fetch happens, not whether the answer survives the menu closing. @@ -105,8 +105,8 @@ export function useComfortPin(open: boolean): ComfortPin { if (open && !everOpen) setEverOpen(true); const shelf = useQuery({ - queryKey: qk.comfortPins(plexLang()), - queryFn: () => api.comfortPins(plexLang()), + queryKey: qk.comfortPins(plexLang(i18n.language)), + queryFn: () => api.comfortPins(plexLang(i18n.language)), enabled: everOpen, // The shelf is hand-curated and short; re-asking on every menu open would be noise. staleTime: 60_000, diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 402b7cea..4ed7ecc9 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -160,7 +160,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } } export function AdminSystem() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); @@ -171,10 +172,10 @@ export function AdminSystem() {
{[ [t("downloads.admin.readyFiles"), String(s.ready_files)], - [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], + [t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)], [ t("downloads.admin.cap"), - s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"), + s.total_cap_bytes ? formatBytes(s.total_cap_bytes, lang) : t("downloads.admin.noCap"), ], ].map(([label, val]) => (
@@ -199,7 +200,7 @@ export function AdminSystem() { className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40" > {u.email} - {formatBytes(u.footprint_bytes)} + {formatBytes(u.footprint_bytes, lang)} setQuotaFor({ id: u.user_id, email: u.email ?? "" })} label={t("downloads.admin.editQuota")} @@ -226,7 +227,7 @@ export function AdminSystem() { {j.user_email} - {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""} + {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""}
))} diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index 02bb05ea..12448122 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import { DownloadRow, IconBtn } from "./downloadRow"; diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 38b7e678..9ea89536 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -240,7 +240,8 @@ export function DownloadRow({ more?: ReactNode; children?: ReactNode; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const running = job.status === "running"; const linkTitle = !hasOwnTitle(job) && !!job.source_url; const sourceLink = linkTitle ? null : job.source_url; @@ -294,16 +295,16 @@ export function DownloadRow({ · {job.asset.container} ) : null} {job.asset?.size_bytes ? ( - · {formatBytes(job.asset.size_bytes)} + · {formatBytes(job.asset.size_bytes, lang)} ) : null} {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} {job.status === "done" && job.created_at ? ( - + {" · "} {" "} - {relativeTime(job.created_at)} + {relativeTime(job.created_at, lang)} ) : null} {job.status === "error" ? ( @@ -360,8 +361,8 @@ export function DownloadRow({
{job.progress}% - {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} - {job.eta_s ? {formatEta(job.eta_s)} : null} + {job.speed_bps ? {formatSpeed(job.speed_bps, lang)} : null} + {job.eta_s ? {formatEta(t, job.eta_s)} : null}
)} @@ -373,7 +374,8 @@ export function DownloadRow({ } export function UsageBar() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Poll live (like the library list) so the footprint updates the moment the worker finishes an // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint @@ -391,8 +393,8 @@ export function UsageBar() {
{t("downloads.usage.title")} - {formatBytes(u.footprint_bytes)} - {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} + {formatBytes(u.footprint_bytes, lang)} + {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "} {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })} diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index 49347015..68d25f65 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -19,10 +19,10 @@ import { SURFACE } from "./ui/surfaces"; // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only // collections have no movie runtime). -function collectionMeta(c: PlexCollection, t: TFunction): string { +function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string { const parts: string[] = []; if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); - const rt = formatRuntime(c.total_runtime_s); + const rt = formatRuntime(c.total_runtime_s, lang); if (rt) parts.push(rt); return parts.join(" · "); } @@ -162,7 +162,7 @@ function NewCollectionPanel({ // A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page. export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const [search, setSearch] = useState(""); @@ -223,7 +223,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: navIndex={i} title={c.title} art={c.art} - subtitle={collectionMeta(c, t)} + subtitle={collectionMeta(c, t, i18n.language)} topRight={ isIntrinsicReadOnly(c) ? ( { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards"; diff --git a/frontend/src/components/plexCards.lang.dom.test.tsx b/frontend/src/components/plexCards.lang.dom.test.tsx index 133070c0..d704a9e7 100644 --- a/frontend/src/components/plexCards.lang.dom.test.tsx +++ b/frontend/src/components/plexCards.lang.dom.test.tsx @@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); const t = (key: string, o?: Record) => o ? `${key}(${Object.values(o).join("|")})` : key; - return { ...actual, useTranslation: () => ({ t }) }; + return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t }) }; }); import { PlexPosterCard, SeasonCard } from "./plexCards"; diff --git a/frontend/src/components/plexCards.langSwitch.dom.test.tsx b/frontend/src/components/plexCards.langSwitch.dom.test.tsx new file mode 100644 index 00000000..3496dd59 --- /dev/null +++ b/frontend/src/components/plexCards.langSwitch.dom.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +// +// E30 S3: a card's numbers follow a language switch. The formatters read the UI language, but the +// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed +// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, act } from "@testing-library/react"; +import { setLanguage } from "../i18n"; +import { PlexPosterCard } from "./plexCards"; +import type { PlexCard } from "../lib/api"; + +afterEach(() => { + cleanup(); + setLanguage("en"); +}); + +const movie: PlexCard = { + id: "m1", + type: "movie", + title: "Heat", + thumb: "/api/plex/image/m1", + duration_seconds: 2 * 3600 + 4 * 60, + rating: 8.3, +}; + +describe("a card after a language switch", () => { + it("says its runtime and rating in the new language", () => { + setLanguage("en"); + render(); + expect(screen.getByText("2 hr 4 min")).toBeTruthy(); + act(() => setLanguage("hu")); + expect(screen.getByText("2 ó 4 p")).toBeTruthy(); + expect(screen.getByText("8,3")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 5c1fa1d9..5b1728f4 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react"; import clsx from "clsx"; import { type ReactNode, useRef } from "react"; -import { formatRuntime } from "../lib/format"; +import { formatDecimal, formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import { seasonTitle } from "../lib/plexDetail"; @@ -287,7 +287,8 @@ export function PlexPosterCard({ /** The show's page, beside a `next` episode. */ onInfo?: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const preview = useMediaPreview(); // An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster // and name, the episode on the line under it (E30, option A). Its own 16:9 still in a poster slot @@ -302,7 +303,7 @@ export function PlexPosterCard({ const acted = ep ?? card; const watched = card.status === "watched"; const actedWatched = acted.status === "watched"; - const runtime = formatRuntime(card.duration_seconds); + const runtime = formatRuntime(card.duration_seconds, lang); const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card); const m = more(card, menuButtonProps, menuOpen); const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined; @@ -317,7 +318,7 @@ export function PlexPosterCard({ const rating = card.rating != null && ( - {card.rating.toFixed(1)} + {formatDecimal(card.rating, lang)} ); return ( @@ -649,11 +650,12 @@ export function EpisodeCard({ menuOpen?: boolean; navIndex?: number; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const watched = ep.status === "watched"; const pct = watched ? 0 : progressPct(ep); const m = more(ep, menuButtonProps, menuOpen); - const runtime = formatRuntime(ep.duration_seconds); + const runtime = formatRuntime(ep.duration_seconds, lang); return ( void; onReset?: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const shown = seconds + ? formatUnit(value, "second", lang) + : `${formatDecimal(value, lang, 1, 0)}${suffix ?? ""}`; return (
{label} - - {Number.isInteger(value) ? value : value.toFixed(1)} - {suffix ?? ""} - + {shown} {onReset && ( { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); const ep = (id: string, season: number, episode: number, position = 0) => ({ diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index dba757a3..20c4af6a 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -58,6 +58,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; import { vocab } from "../lib/vocab"; +import { formatDate, formatDecimal } from "../lib/format"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, @@ -82,8 +83,8 @@ export function PlexInfoView({ onFilter: (patch: Partial) => void; onOpenPerson: (name: string) => void; }) { - const { t } = useTranslation(); - const lang = plexLang(); + const { t, i18n } = useTranslation(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); return (
@@ -137,13 +138,13 @@ export function PlexPersonView({ onInfoCard: (card: PlexCard) => void; onFilter: (patch: Partial) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // ONE menu for this whole page — the "known for" rail and the two filmography grids are three // lists on one screen, and the popover anchors where it opened, so a second controller would only // mean a second closed popover to keep shut (see ui/ContextMenu). const menu = useContextMenu(); - const lang = plexLang(); + const lang = plexLang(i18n.language); const personKey = qk.plexPerson(name, lang); const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) }); // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls @@ -255,12 +256,13 @@ export function PlexPersonView({ {(d.birthday || d.place_of_birth) && (

{t("plex.person.born")}:{" "} - {[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")} + {[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")}

)} {d.deathday && (

- {t("plex.person.died")}: {d.deathday} + {t("plex.person.died")}:{" "} + {formatDate(d.deathday, lang)}

)} {d.also_known_as.length > 0 && ( @@ -345,10 +347,10 @@ export function PlexShowView({ onFilter?: (patch: Partial) => void; onOpenPerson?: (name: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; - const lang = plexLang(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang), @@ -535,7 +537,7 @@ export function PlexShowView({ > {/* the rating gold — its own token, not --warn: a score is not a warning */} - {score.toFixed(1)} + {formatDecimal(score, lang)} ); })()} @@ -671,9 +673,9 @@ export function PlexSeasonView({ onBack: () => void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const lang = plexLang(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang), diff --git a/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx b/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx index 866d5e13..55309e12 100644 --- a/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx @@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); afterEach(cleanup); diff --git a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx index 7adb0eed..42ff32b0 100644 --- a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx @@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); afterEach(() => { diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index bf2f83e6..ce2d6296 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -132,7 +132,8 @@ export default function DataTable({ // the hidden-filter cue leaves those out, it would name them twice. surfacedFilters?: string[]; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const initial = loadPersist(persistKey); // Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise // the table keeps its own, seeded from and saved to persistKey. @@ -212,7 +213,7 @@ export default function DataTable({ setPage(0); }, [resetFiltersToken, commitFilters]); - const sorted = sortRows(filterRows(rows, columns, filters), columns, sort); + const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang); // size <= 0 means "All" — one page with every row. const allRows = size <= 0; diff --git a/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx index 00ab192c..7c477db9 100644 --- a/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx +++ b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx @@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, o?: { name?: string; count?: number }) => o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key, }), diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx index 8fd0c962..246bd3c3 100644 --- a/frontend/src/components/ui/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -30,7 +30,7 @@ describe("ItemCount", () => { render(); // 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`); + expect(text()).toBe(`${formatNumber(192045, "en")} items`); }); // The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on @@ -52,7 +52,9 @@ describe("ItemCount", () => { 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(formatNumber(1_000_000)); + expect(screen.getByTestId("item-count").getAttribute("title")).toBe( + formatNumber(1_000_000, "en"), + ); }); 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 7d1acb2b..38bc47bf 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -14,7 +14,8 @@ const OVERFLOW_AT = 1_000_000; // "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural) // so any surface can reuse it for whatever it counts. export default function ItemCount({ count }: { count?: number }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just // show the icon until the number arrives. const label = @@ -22,12 +23,12 @@ export default function ItemCount({ count }: { count?: number }) { ? "" : t("common.itemCount", { count, - formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count), + formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang), }); return (