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
This commit is contained in:
2026-09-29 22:39:57 +02:00
parent 67c9f10153
commit aac79ffa53
105 changed files with 1001 additions and 494 deletions
+8 -1
View File
@@ -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 |
+48 -15
View File
@@ -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",
+5 -4
View File
@@ -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 (
+3 -2
View File
@@ -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")
+6 -4
View File
@@ -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<LogsUrlState>(() =>
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 (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
<dd className="tabular-nums">{formatDateTimeSec(row.ts, lang)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
@@ -93,7 +93,7 @@ describe("the last sign-in column (E22 S1)", () => {
</QueryClientProvider>,
);
expect(await screen.findByText(relativeTime(at))).toBeTruthy();
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2);
});
});
+13 -9
View File
@@ -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 }) {
<Tooltip
hint={
u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at) })
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
: u.is_demo
? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone")
}
>
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
</span>
</Tooltip>
</div>
@@ -440,7 +441,8 @@ function PendingAccessRowView({
onActivate: () => void;
onResend: () => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
@@ -449,7 +451,7 @@ function PendingAccessRowView({
{row.created_at && (
<span>
{t("users.pendingAccess.signedUp", {
time: formatDateTime(row.created_at),
time: formatDateTime(row.created_at, lang),
})}
</span>
)}
@@ -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),
},
)}
</p>
@@ -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 (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
@@ -939,7 +943,7 @@ function InviteRow({
{inv.requested_at && (
<div className="text-2xs text-muted">
{t("settings.invites.requested", {
time: formatDateTime(inv.requested_at),
time: formatDateTime(inv.requested_at, lang),
})}
</div>
)}
+8 -4
View File
@@ -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({
+1
View File
@@ -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);
+5 -4
View File
@@ -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) => <AboutCell text={c.description} channelId={c.id} />,
},
subsColumn<DiscoveredChannel>(t),
subsColumn<DiscoveredChannel>(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) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count, lang)}</span>
),
},
{
@@ -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,
+4 -3
View File
@@ -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.
@@ -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<typeof import("react-i18next")>();
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
});
@@ -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"),
},
];
+4 -2
View File
@@ -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,
+3 -2
View File
@@ -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"
>
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
<span className="tabular-nums">{formatDateTimeSec(s.started_at, lang)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
@@ -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),
})}
</span>
</span>
@@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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", () => ({
@@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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", () => ({
+3 -1
View File
@@ -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 =
+3 -1
View File
@@ -31,12 +31,14 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// the real instance: these tests switch the language with setLanguage
const real = (await import("i18next")).default;
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" },
i18n: real,
}),
};
});
+15 -8
View File
@@ -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({
<span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{plex.rating.toFixed(1)}
{formatDecimal(plex.rating, lang)}
</span>
)}
</p>
@@ -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),
+6 -4
View File
@@ -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({
<div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted">
{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}`),
})}
</p>
@@ -607,7 +608,8 @@ function Attachments({
onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>;
}) {
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({
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size, lang)}</span>
</div>
{!readOnly && (
<IconButton
@@ -8,7 +8,10 @@ import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
const conversation = (id: number, name: string) => ({
@@ -23,7 +23,7 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
@@ -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.
@@ -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,
}),
+6 -5
View File
@@ -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),
})}
</span>
)}
<span>
· {relativeTime(active.published_at)}
· {relativeTime(active.published_at, lang)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{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),
})}
</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} ·{" "}
· {relativeTime(detail.data.published_at, lang)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
+5 -4
View File
@@ -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() {
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
+6 -4
View File
@@ -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<PlSort>(() => 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({
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
</div>
</div>
</Pressable>
+3 -3
View File
@@ -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
@@ -27,7 +27,12 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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 }));
@@ -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 (
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, opts?: Record<string, unknown>) =>
opts?.title ? `${key}:${opts.title}` : key,
}),
+2 -2
View File
@@ -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();
@@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, opts?: Record<string, unknown>) =>
opts?.name ? `${key}:${opts.name}` : key,
}),
+23 -31
View File
@@ -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 ? (
<Pressable
onClick={() => onFilterRating(Math.floor(score))}
@@ -78,12 +60,12 @@ function Score({
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
{shown}
</Pressable>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
{shown}
</span>
);
}
@@ -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({
<span className={chipCls}>
<Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
</span>
</span>
)}
@@ -171,7 +153,8 @@ function FactsPanel({
detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => 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({
</Filterable>
)}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span>
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.studio && (
<Filterable
@@ -25,7 +25,12 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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.
+3 -3
View File
@@ -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
@@ -10,7 +10,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }),
useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }),
}));
import { PlexPlayerSettings } from "./PlexPlayerSettings";
@@ -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 })}
/>
<Slider
@@ -118,7 +118,7 @@ export function PlexPlayerSettings(props: {
min={0.1}
max={5}
step={0.1}
suffix="s"
seconds
onChange={(v) => patchPrefs({ fineSeekStep: v })}
/>
<Toggle
@@ -148,7 +148,7 @@ export function PlexPlayerSettings(props: {
min={0}
max={10}
step={1}
suffix="s"
seconds
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
/>
)}
@@ -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<typeof import("react-i18next")>()),
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<typeof import("react-i18next")>()),
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<typeof import("../lib/notifications")>()),
+3 -2
View File
@@ -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 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
</div>
)}
</div>
+2 -2
View File
@@ -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),
+4 -1
View File
@@ -38,7 +38,8 @@ export function PlexStatsOverlay({
durationRef: RefObject<number>;
onClose: () => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [rows, setRows] = useState<StatRow[]>([]);
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,
@@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
});
import Scheduler from "./Scheduler";
@@ -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<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
});
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
+29 -25
View File
@@ -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<string, string> = {
@@ -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"] }) {
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{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)
: ""}
</span>
</div>
@@ -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({
</div>
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished)) || undefined}
title={(!job.running && formatDateTime(job.last_finished, lang)) || undefined}
>
{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 ? (
<span className="text-danger"> · {job.last_error}</span>
) : job.result ? (
<span> · {jobResultText(t, job.result)}</span>
<span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
@@ -270,7 +272,7 @@ function JobRow({
) : next != null ? (
<>
<div className="uppercase tracking-wide text-3xs">{t("scheduler.nextRun")}</div>
<div>{fmtCountdown(next)}</div>
<div>{fmtCountdown(t, next, lang)}</div>
</>
) : (
"—"
@@ -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({
)}
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
default: formatNumber(m.revalidate_batch_default),
default: formatNumber(m.revalidate_batch_default, lang),
})}
</div>
</div>
@@ -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)}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={formatNumber(data.queue.channels_deep_pending)}
value={formatNumber(data.queue.channels_deep_pending, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={formatNumber(data.queue.videos_pending_enrich)}
value={formatNumber(data.queue.videos_pending_enrich, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={formatNumber(data.queue.videos_pending_shorts)}
value={formatNumber(data.queue.videos_pending_shorts, lang)}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={formatNumber(data.queue.videos_live_refresh)}
value={formatNumber(data.queue.videos_live_refresh, lang)}
/>
</div>
</div>
@@ -684,15 +688,15 @@ export default function Scheduler() {
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{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),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: formatNumber(data.quota.remaining_today),
formatted: formatNumber(data.quota.remaining_today, lang),
})}
</span>
</div>
+14 -7
View File
@@ -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 }) {
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
<span className="text-muted text-xs">
{formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
</span>
</div>
<Slider
className="mt-1"
@@ -522,7 +525,8 @@ function SignInMethods({ me }: { me: Me }) {
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => 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 (
<Section title={t("settings.plexSync.title")}>
@@ -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 (
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) {
{!me.is_demo && (
<div className="text-xs text-muted" data-testid="account-last-login">
{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")}
</div>
)}
+6 -2
View File
@@ -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.<id>") 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");
+25 -19
View File
@@ -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)}
</SettingRow>
)}
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{formatNumber(s.my_videos)}
{formatNumber(s.my_videos, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{formatNumber(s.quota_remaining_today)}
{formatNumber(s.quota_remaining_today, lang)}
</SettingRow>
</div>
) : status.isError ? (
@@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) {
<>
<div className="space-y-1.5 text-sm">
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{formatNumber(usage.data.today)}
{formatNumber(usage.data.today, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>
{formatNumber(usage.data.last_7d)}
{formatNumber(usage.data.last_7d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>
{formatNumber(usage.data.last_30d)}
{formatNumber(usage.data.last_30d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>
{formatNumber(usage.data.all_time)}
{formatNumber(usage.data.all_time, lang)}
</SettingRow>
</div>
{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]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>
@@ -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<number>(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() {
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
d: formatUnit(data.range_days, "day"),
units: formatNumber(grandTotal),
d: formatUnit(data.range_days, "day", lang),
units: formatNumber(grandTotal, lang),
})}
</div>
{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),
})}
>
<div
className="w-full bg-accent rounded-t"
@@ -333,7 +338,8 @@ function SystemStats() {
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => 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}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total)}
{formatNumber(row.total, lang)}
</span>
</Pressable>
{/* Proportion bar */}
@@ -363,8 +369,8 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span>
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>
+10 -6
View File
@@ -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 (
<div className="pointer-events-auto shrink-0">
@@ -132,7 +134,9 @@ export default function SyncStatus({
) : (
<Database className="w-3.5 h-3.5 shrink-0" />
)}
<span className="text-fg font-medium tabular-nums">{formatViews(data.my_videos)}</span>
<span className="text-fg font-medium tabular-nums">
{formatCompact(data.my_videos, lang)}
</span>
{needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
@@ -149,10 +153,10 @@ export default function SyncStatus({
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
<span className="text-fg font-medium">{formatCompact(data.my_videos, lang)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
{formatCompact(data.total_videos, lang)} {t("header.sync.total")}
</span>
</div>
{showMain && (
@@ -11,7 +11,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
vi.mock("../lib/api", () => ({
+2 -2
View File
@@ -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),
@@ -19,7 +19,10 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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 },
+4 -3
View File
@@ -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<File | null>(null);
const [name, setName] = useState("");
@@ -148,7 +149,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
<>
<FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span>
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
</>
) : (
<>
@@ -172,7 +173,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
{tooBig && (
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0) })}
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0, lang) })}
</p>
)}
+11 -4
View File
@@ -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 && (
<span title={`${formatNumber(video.view_count)} ${t("card.views")}`}>
{formatViews(video.view_count)}
<span title={`${formatNumber(video.view_count, lang)} ${t("card.views")}`}>
{formatCompact(video.view_count, lang)}
{withWord && ` ${t("card.views")}`}
</span>
);
const published = (
<>
{relativeTime(video.published_at)}
{relativeTime(video.published_at, lang)}
{video.published_at && (
<>
{" · "}
+3 -2
View File
@@ -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);
+1 -1
View File
@@ -317,7 +317,7 @@ export default function WatchPage() {
{meta.title || T.file}
</h1>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
+3 -2
View File
@@ -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 (
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
{isFullySynced(c) && (
@@ -24,7 +25,7 @@ export function StoredCell({ c }: { c: ManagedChannel }) {
/>
</Tooltip>
)}
{formatNumber(c.stored_videos)}
{formatNumber(c.stored_videos, lang)}
</span>
);
}
@@ -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,
+10 -4
View File
@@ -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 extends { subscriber_count: number | null }>(t: TFunction): Column<T> {
export function subsColumn<T extends { subscriber_count: number | null }>(
t: TFunction,
lang: string,
): Column<T> {
return {
key: "subs",
header: t("channels.cols.subs"),
@@ -23,7 +26,7 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count, lang)}</span>
),
};
}
@@ -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<typeof ChannelTagsControl>["userTags"];
bumpPriority: (id: string, delta: number) => void;
@@ -69,6 +74,7 @@ export interface ChannelColumnDeps {
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const {
t,
lang,
prioOptions,
userTags,
bumpPriority,
@@ -144,7 +150,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
render: (c) => <StoredCell c={c} />,
},
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
{ ...subsColumn<ManagedChannel>(t, lang), width: "6rem" },
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
@@ -165,7 +171,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
{formatTotalHours(c.total_duration_seconds, lang)}
</span>
),
},
@@ -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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
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"));
});
});
+3 -3
View File
@@ -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,
+6 -5
View File
@@ -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() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[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]) => (
<div key={label} className={`${SURFACE.card} p-3`}>
@@ -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"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes, lang)}</span>
<IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
label={t("downloads.admin.editQuota")}
@@ -226,7 +227,7 @@ export function AdminSystem() {
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""}
</span>
</div>
))}
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
import { DownloadRow, IconBtn } from "./downloadRow";
+11 -9
View File
@@ -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({
<span className="text-muted uppercase"> · {job.asset.container}</span>
) : null}
{job.asset?.size_bytes ? (
<span className="text-muted"> · {formatBytes(job.asset.size_bytes)}</span>
<span className="text-muted"> · {formatBytes(job.asset.size_bytes, lang)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span className="text-muted" title={formatDateTime(job.created_at)}>
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
{" · "}
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at)}
{relativeTime(job.created_at, lang)}
</span>
) : null}
{job.status === "error" ? (
@@ -360,8 +361,8 @@ export function DownloadRow({
</div>
<div className="text-xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
{job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
{job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
</div>
</>
)}
@@ -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() {
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{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 })}
+4 -4
View File
@@ -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) ? (
<Lock
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
const t = (key: string, o?: Record<string, unknown>) =>
o ? `${key}(${Object.values(o).join("|")})` : key;
return { ...actual, useTranslation: () => ({ t }) };
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t }) };
});
import { PlexPosterCard, SeasonCard } from "./plexCards";
@@ -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(<PlexPosterCard card={movie} onOpen={vi.fn()} />);
expect(screen.getByText("2 hr 4 min")).toBeTruthy();
act(() => setLanguage("hu"));
expect(screen.getByText("2 ó 4 p")).toBeTruthy();
expect(screen.getByText("8,3")).toBeTruthy();
});
});
+8 -6
View File
@@ -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 && (
<span className="inline-flex items-center gap-0.5">
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
{card.rating.toFixed(1)}
{formatDecimal(card.rating, lang)}
</span>
);
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 (
<MediaCard
dense
+11 -5
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton";
import { Slider as Range, CheckRow } from "./ui/NativeControls";
import { formatDecimal, formatUnit } from "../lib/format";
// --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) {
@@ -17,6 +18,7 @@ export function Slider({
max,
step,
suffix,
seconds,
onChange,
onReset,
}: {
@@ -25,20 +27,24 @@ export function Slider({
min: number;
max: number;
step: number;
/** A symbol after the number ("%", "px"). */
suffix?: string;
/** The value is seconds: named in the UI language's unit ("0.5 sec" / "0,5 mp"). */
seconds?: boolean;
onChange: (v: number) => 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 (
<div className="px-1 py-1">
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
<span>{label}</span>
<span className="flex items-center gap-1.5">
<span className="tabular-nums text-white/60">
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix ?? ""}
</span>
<span className="tabular-nums text-white/60">{shown}</span>
{onReset && (
<IconButton
variant="overlay"
@@ -21,7 +21,10 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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) => ({
+13 -11
View File
@@ -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<PlexFilters>) => 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 (
<div className="w-[90%] max-w-400 mx-auto">
@@ -137,13 +138,13 @@ export function PlexPersonView({
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => 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<PlexCard>();
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) && (
<p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
{[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")}
</p>
)}
{d.deathday && (
<p>
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
{formatDate(d.deathday, lang)}
</p>
)}
{d.also_known_as.length > 0 && (
@@ -345,10 +347,10 @@ export function PlexShowView({
onFilter?: (patch: Partial<PlexFilters>) => 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 */}
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)}
{formatDecimal(score, lang)}
</Pressable>
);
})()}
@@ -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),
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
afterEach(cleanup);
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
afterEach(() => {
+3 -2
View File
@@ -132,7 +132,8 @@ export default function DataTable<T>({
// 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<T>({
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;
@@ -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,
}),
@@ -30,7 +30,7 @@ describe("ItemCount", () => {
render(<ItemCount count={192045} />);
// 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(<ItemCount count={1_000_000} />);
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", () => {
+4 -3
View File
@@ -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 (
<span
data-testid="item-count"
title={count == null ? undefined : formatNumber(count)}
title={count == null ? undefined : formatNumber(count, lang)}
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
@@ -4,6 +4,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
import "../../i18n"; // the real instance, in English: the component names time in the UI language
import { RelativeTime } from "./RelativeTime";
afterEach(cleanup);
@@ -14,8 +15,8 @@ describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO));
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
});
@@ -23,7 +24,7 @@ describe("RelativeTime", () => {
const ms = Date.parse(THREE_DAYS_AGO);
const { container } = render(<RelativeTime at={ms} seconds />);
expect(container.querySelector("time")!.getAttribute("title")).toBe(
formatDateTimeSec(THREE_DAYS_AGO),
formatDateTimeSec(THREE_DAYS_AGO, "en"),
);
});
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { formatDateTime, formatDateTimeSec, relativeFromMs } from "../../lib/format";
// docs/DESIGN.md "Copy": a relative time ("3d ago") says how recent something is in a list, and the
@@ -16,14 +17,15 @@ export function RelativeTime({
empty?: string;
className?: string;
}) {
const lang = useTranslation().i18n.language;
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
const text = Number.isFinite(ms) ? relativeFromMs(ms) : "";
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : "";
if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString();
return (
<time
dateTime={iso}
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)}
title={seconds ? formatDateTimeSec(iso, lang) : formatDateTime(iso, lang)}
className={className}
>
{text}
@@ -18,7 +18,10 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { priorityChoices } from "../lib/channelOrder";
import { compareText } from "../lib/collate";
import { channelYouTubeUrl } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -40,7 +41,8 @@ export function useChannelActions({
onPickPriority: (c: ManagedChannel, priority: number) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const confirm = useConfirm();
const me = useMe();
const isAdmin = me.role === "admin";
@@ -166,7 +168,7 @@ export function useChannelActions({
return {
...base,
items: [...tags]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => compareText(a.name, b.name, lang))
.map((tg) => ({
id: `tag-${tg.id}`,
label: tg.name,
@@ -13,12 +13,12 @@ import { reinsertAt } from "../lib/reinsert";
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
* driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
export function usePlexPlaylistEdits(playlistId: number) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
// The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE
// key serves the read and every write below (setQueryData matches exactly).
const lang = plexLang();
const lang = plexLang(i18n.language);
const key = qk.plexPlaylist(playlistId, lang);
const q = useQuery({
queryKey: key,
+7 -5
View File
@@ -67,11 +67,13 @@ export function setLanguage(code: LangCode): void {
document.documentElement.lang = code;
}
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of
// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which
// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks).
export function plexLang(): "en" | "hu" {
return i18n.language?.startsWith("hu") ? "hu" : "en";
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse a UI language to one of those for
// the `?lang=` param / query keys. It takes the language instead of reading the singleton (E30 S3):
// under the React Compiler a component's `plexLang()` was a value with no reactive input, so it kept
// the language the component first rendered in — a switch left every Plex query on the old language.
// A component passes `i18n.language` from useTranslation; non-component code the singleton's.
export function plexLang(lang: string | undefined): "en" | "hu" {
return lang?.startsWith("hu") ? "hu" : "en";
}
export default i18n;
+3 -5
View File
@@ -4,7 +4,7 @@
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
@@ -86,9 +86,7 @@ function localizeDetail(d: unknown): string {
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(o.limit || 0) / 1_073_741_824,
);
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
@@ -117,7 +115,7 @@ function localizeDetail(d: unknown): string {
: t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
+13
View File
@@ -0,0 +1,13 @@
// Alphabetical order in the UI language (E30 S3): Hungarian puts "Ö" after "O" and "Cs" after "Cu". A
// bare `localeCompare` compares in the BROWSER's locale; the server sorts by the same ICU rules
// (backend `app/collation.py`), so a list sorted here agrees with one the server sorted. Its own
// module, apart from lib/format.ts, because it needs no i18n: the pure sort helpers import it.
const collators = new Map<string, Intl.Collator>();
export function compareText(a: string, b: string, lang: string): number {
let c = collators.get(lang);
if (!c) {
c = new Intl.Collator(lang);
collators.set(lang, c);
}
return c.compare(a, b);
}
+21 -7
View File
@@ -27,37 +27,51 @@ const columns = [
describe("sortRows", () => {
it("returns the same array reference when there is no sort", () => {
expect(sortRows(rows, columns, null)).toBe(rows);
expect(sortRows(rows, columns, null, "en")).toBe(rows);
});
it("returns the input unchanged for a column that has no sortValue", () => {
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows);
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" }, "en")).toBe(rows);
});
it("sorts numbers by numeric difference, not string order", () => {
// string order would put "100" before "30"; numeric must not.
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views);
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }, "en").map((r) => r.views);
expect(asc).toEqual([5, 30, 100]);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }, "en").map((r) => r.views);
expect(desc).toEqual([100, 30, 5]);
});
it("sorts strings with locale compare, ascending and descending", () => {
expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([
expect(sortRows(rows, columns, { key: "name", dir: "asc" }, "en").map((r) => r.name)).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([
expect(sortRows(rows, columns, { key: "name", dir: "desc" }, "en").map((r) => r.name)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
// E30 S3: text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after
// "O" and "Cs" after "Cu". Both languages are asserted and differ, so a bare `localeCompare` (one
// runtime locale for both) cannot pass on any machine.
it("sorts text in the UI language's alphabet", () => {
const words = ["Zorró", "Ötödik", "oroszlán", "Csillag", "Életrajzi", "Cukor"].map((name) => ({
name,
views: 0,
}));
const by = (lang: string) =>
sortRows(words, columns, { key: "name", dir: "asc" }, lang).map((r) => r.name);
expect(by("hu")).toEqual(["Cukor", "Csillag", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
expect(by("en")).toEqual(["Csillag", "Cukor", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
});
it("does not mutate the input array", () => {
const before = rows.map((r) => r.name);
sortRows(rows, columns, { key: "name", dir: "asc" });
sortRows(rows, columns, { key: "name", dir: "asc" }, "en");
expect(rows.map((r) => r.name)).toEqual(before);
});
});
+7 -4
View File
@@ -2,6 +2,8 @@
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
import { compareText } from "./collate";
export type SortState = { key: string; dir: "asc" | "desc" } | null;
interface Sortable<T> {
@@ -9,9 +11,10 @@ interface Sortable<T> {
sortValue?: (row: T) => string | number;
}
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
* language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
* `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
if (!sort) return rows;
const get = columns.find((c) => c.key === sort.key)?.sortValue;
if (!get) return rows;
@@ -21,7 +24,7 @@ export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState):
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
: compareText(String(av), String(bv), lang);
return sort.dir === "asc" ? cmp : -cmp;
});
}
+8 -2
View File
@@ -3,6 +3,7 @@
// chips can label a sort without re-deriving the mapping.
import type { Video } from "./api";
import type { SortOption } from "./columnSort";
import { compareText } from "./collate";
export type SortKey =
| "date"
@@ -100,7 +101,12 @@ const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | nul
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
export function sortYtResults(
items: Video[],
key: SortKey,
dir: "asc" | "desc",
lang: string,
): Video[] {
const value = YT_SORT_VALUE[key];
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
const sign = dir === "asc" ? 1 : -1;
@@ -109,7 +115,7 @@ export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc")
have.sort((a, b) => {
const x = value(a)!;
const y = value(b)!;
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y));
});
return [...have, ...missing];
}
+34 -14
View File
@@ -46,7 +46,7 @@ describe("YT_SORT_KEYS", () => {
describe("sortYtResults", () => {
it("leaves the provider's ranking alone on `relevance`", () => {
expect(titles(sortYtResults(RESULTS, "relevance", "desc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "relevance", "desc", "en"))).toEqual([
"b-old-short",
"a-new-long",
"c-mid-mid",
@@ -54,12 +54,12 @@ describe("sortYtResults", () => {
});
it("orders by upload date in both directions", () => {
expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "date", "desc", "en"))).toEqual([
"a-new-long",
"c-mid-mid",
"b-old-short",
]);
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([
"b-old-short",
"c-mid-mid",
"a-new-long",
@@ -67,14 +67,18 @@ describe("sortYtResults", () => {
});
it("orders by views and by length", () => {
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short");
expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short");
});
it("orders by title alphabetically, not by code point", () => {
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })];
expect(titles(sortYtResults(accented, "title", "asc"))).toEqual(["Ábel", "alma", "Zebra"]);
expect(titles(sortYtResults(accented, "title", "asc", "en"))).toEqual([
"Ábel",
"alma",
"Zebra",
]);
});
it("sinks results with no value to the END in both directions", () => {
@@ -84,8 +88,16 @@ describe("sortYtResults", () => {
v({ title: "long", duration_seconds: 900 }),
v({ title: "short", duration_seconds: 60 }),
];
expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]);
expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]);
expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([
"long",
"short",
"unknown",
]);
expect(titles(sortYtResults(mixed, "duration", "asc", "en"))).toEqual([
"short",
"long",
"unknown",
]);
});
it("keeps the provider's ranking inside a tie, in both directions", () => {
@@ -95,19 +107,27 @@ describe("sortYtResults", () => {
v({ title: "second", view_count: 100 }),
v({ title: "third", view_count: 5 }),
];
expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]);
expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]);
expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([
"first",
"second",
"third",
]);
expect(titles(sortYtResults(tied, "popular", "asc", "en"))).toEqual([
"third",
"first",
"second",
]);
});
it("does not reorder anything for a key it cannot honour", () => {
// A pref stored before this view offered a control can name `shuffle` or `priority`.
expect(titles(sortYtResults(RESULTS, "priority", "desc"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "priority", "desc", "en"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS));
});
it("does not mutate the array it was given", () => {
const input = [...RESULTS];
sortYtResults(input, "date", "asc");
sortYtResults(input, "date", "asc", "en");
expect(titles(input)).toEqual(titles(RESULTS));
});
});
+71 -28
View File
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RELATIVE_UNITS,
formatBytes,
formatCompact,
formatCountOrDash,
formatDecimal,
formatMoney,
formatDate,
formatDateTime,
formatDateTimeSec,
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn);
i18nStub.language = "hu";
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu);
});
}
it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
i18nStub.language = "en";
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
});
it("clamps a future timestamp instead of counting up", () => {
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow");
});
it("renders nothing for a timestamp it cannot read", () => {
expect(relativeTime("not-a-date")).toBe("");
expect(relativeTime(null)).toBe("");
expect(relativeFromMs(NaN)).toBe("");
expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
expect(relativeTime(null, i18nStub.language)).toBe("");
expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
});
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
@@ -135,9 +140,9 @@ describe("formatNumber", () => {
});
it("groups in the UI language, not in the runtime's", () => {
const asEnglish = formatNumber(1234567);
const asEnglish = formatNumber(1234567, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatNumber(1234567);
const asHungarian = formatNumber(1234567, i18nStub.language);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
@@ -145,24 +150,26 @@ describe("formatNumber", () => {
});
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
i18nStub.language = "hu";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
i18nStub.language = "en";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
});
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
expect(formatNumber(null)).toBe("");
expect(formatNumber(undefined)).toBe("");
expect(formatNumber(NaN)).toBe("");
expect(formatNumber(Infinity)).toBe("");
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
expect(formatNumber(null, i18nStub.language)).toBe("");
expect(formatNumber(undefined, i18nStub.language)).toBe("");
expect(formatNumber(NaN, i18nStub.language)).toBe("");
expect(formatNumber(Infinity, i18nStub.language)).toBe("");
expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one
});
it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu";
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe(
`${new Intl.NumberFormat("hu").format(12345)} ó`,
);
});
});
@@ -185,18 +192,18 @@ describe("formatRuntime", () => {
];
it.each(CASES)("formats %o as %o", (sec, expected) => {
expect(formatRuntime(sec)).toBe(expected);
expect(formatRuntime(sec, i18nStub.language)).toBe(expected);
});
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
i18nStub.language = "hu";
expect(formatRuntime(5025)).toBe("1 ó 23 p");
expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
i18nStub.language = "en";
});
it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) {
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min");
}
});
});
@@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => {
it("formats a timestamp in the UI language, not in the runtime's", () => {
const iso = "2026-09-17T08:30:14Z";
const asEnglish = formatDateTime(iso);
const asEnglish = formatDateTime(iso, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatDateTime(iso);
const asHungarian = formatDateTime(iso, i18nStub.language);
expect(asEnglish).not.toBe(asHungarian);
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
});
@@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => {
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
process.env.TZ = "UTC";
const iso = "2026-09-17T08:30:14Z";
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
i18nStub.language = "hu";
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30");
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
});
@@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => {
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
});
});
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
// asserted and differ, so an implementation that ignores the language cannot pass.
describe("numbers in the UI language", () => {
it("writes a rating with the language's decimal mark", () => {
expect(formatDecimal(8.25, "en")).toBe("8.3");
expect(formatDecimal(8.25, "hu")).toBe("8,3");
expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal
expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one
});
it("writes a big count compact, with the language's suffix", () => {
// Intl keeps the number and its suffix together with a no-break space
const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " ");
expect(formatCompact(1234, "en")).toBe("1.2K");
expect(hu(1234)).toBe("1,2 E");
expect(formatCompact(3_400_000, "en")).toBe("3.4M");
expect(hu(3_400_000)).toBe("3,4 M");
expect(formatCompact(999, "hu")).toBe("999");
expect(formatCompact(null, "hu")).toBe("");
expect(formatCountOrDash(null, "hu")).toBe("—");
});
it("writes a file size with the language's decimal mark", () => {
expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB");
expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB");
expect(formatBytes(501747, "hu")).toBe("490 KB");
expect(formatBytes(0, "hu")).toBe("0 B");
});
it("writes a dollar sum compact, never padded", () => {
expect(formatMoney(160_000_000, "en")).toBe("$160M");
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
});
});
+98 -57
View File
@@ -1,12 +1,36 @@
import type { TFunction } from "i18next";
import i18n from "../i18n";
export function relativeTime(iso: string | null): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime());
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
//
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style
// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
// dependency the compiler can see; the signature makes forgetting it a type error.
// Intl formatters are expensive to build and every grid formats one per card: one per language ×
// options, built lazily. Keyed by language because the user can switch it at runtime.
const numberFormats = new Map<string, Intl.NumberFormat>();
function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat {
const key = `${lang}|${JSON.stringify(opts)}`;
let f = numberFormats.get(key);
if (!f) {
f = new Intl.NumberFormat(lang, opts);
numberFormats.set(key, f);
}
return f;
}
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
export function formatBytes(bytes: number | null | undefined): string {
export function relativeTime(iso: string | null, lang: string): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), lang);
}
/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB",
* 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */
export function formatBytes(bytes: number | null | undefined, lang: string): string {
const n = Number(bytes);
if (!n || n < 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string {
v /= 1024;
i++;
}
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang);
return `${num} ${units[i]}`;
}
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
export function formatSpeed(bps: number | null | undefined): string {
export function formatSpeed(bps: number | null | undefined, lang: string): string {
if (!bps) return "";
return `${formatBytes(bps)}/s`;
return `${formatBytes(bps, lang)}/s`;
}
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
@@ -65,18 +90,18 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
* client-side notifications whose timestamps are already numbers). One implementation shared
* instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
* caller's `|| "—"` fallback covers it like a missing one. */
export function relativeFromMs(ms: number): string {
export function relativeFromMs(ms: number, lang: string): string {
const secs = Math.max(0, (Date.now() - ms) / 1000);
if (!Number.isFinite(secs)) return "";
// Sub-minute is the one case Intl has no wording for — it would say "0m ago".
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow");
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang });
let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break;
div = unitSecs;
unit = unitName;
}
return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit);
return relativeFormatter(lang).format(-Math.floor(secs / div), unit);
}
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
@@ -100,15 +125,17 @@ export function formatDuration(sec: number | null): string {
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
export type DurationUnit = "second" | "minute" | "hour" | "day";
const unitFormatters = new Map<string, Intl.NumberFormat>();
export function formatUnit(value: number, unit: DurationUnit): string {
const key = `${i18n.language}|${unit}`;
let f = unitFormatters.get(key);
if (!f) {
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
unitFormatters.set(key, f);
}
return f.format(value);
export function formatUnit(
value: number,
unit: DurationUnit,
lang: string,
digits?: number,
): string {
const fraction =
digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits };
return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format(
value,
);
}
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
@@ -118,70 +145,84 @@ export function formatUnit(value: number, unit: DurationUnit): string {
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
export function formatRuntime(sec?: number | null): string {
export function formatRuntime(sec: number | null | undefined, lang: string): string {
if (!sec) return "";
if (sec < 60) return formatUnit(Math.floor(sec), "second");
if (sec < 60) return formatUnit(Math.floor(sec), "second", lang);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
return h
? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}`
: formatUnit(m, "minute", lang);
}
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
// key for any unmapped/legacy value.
export function quotaActionLabel(action: string): string {
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
export function quotaActionLabel(t: TFunction, action: string): string {
return t(`quotaActions.${action}`, { defaultValue: action });
}
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
export function formatEta(seconds: number): string {
if (seconds <= 0) return i18n.t("time.eta.done");
export function formatEta(t: TFunction, seconds: number): string {
if (seconds <= 0) return t("time.eta.done");
const hours = seconds / 3600;
if (hours < 1) return i18n.t("time.eta.lessThanHour");
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
if (hours < 1) return t("time.eta.lessThanHour");
if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) });
return t("time.eta.days", { count: Math.round(hours / 24) });
}
/** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */
export function formatTotalHours(sec: number): string {
export function formatTotalHours(sec: number, lang: string): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
return h >= 1
? formatUnit(h, "hour", lang)
: formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang);
}
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
const numberFormatters = new Map<string, Intl.NumberFormat>();
/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
* `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
* browser showed space-grouped figures and vice versa — the number half of the same defect
* `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
* `|| "—"` fallback covers it like a missing one. */
export function formatNumber(n: number | null | undefined): string {
export function formatNumber(n: number | null | undefined, lang: string): string {
if (n == null || !Number.isFinite(n)) return "";
const lang = i18n.language;
let formatter = numberFormatters.get(lang);
if (!formatter) {
formatter = new Intl.NumberFormat(lang);
numberFormatters.set(lang, formatter);
}
return formatter.format(n);
return numberFormat(lang).format(n);
}
export function formatViews(n: number | null): string {
if (n == null) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
return String(n);
/** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed`
* always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */
export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string {
const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits };
return numberFormat(lang, opts).format(n);
}
/** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is
* pinned for the reason `formatMoney` gives. Empty for null. */
export function formatCompact(n: number | null | undefined, lang: string): string {
if (n == null || !Number.isFinite(n)) return "";
const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const;
return numberFormat(lang, opts).format(n);
}
/** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's
* language. The minimum is load-bearing, not a default written out: given only a maximum, a compact
* format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24
* (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */
export function formatMoney(n: number, lang: string): string {
return numberFormat(lang, {
notation: "compact",
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(n);
}
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
export function formatCountOrDash(n: number | null | undefined, lang: string): string {
return n != null ? formatCompact(n, lang) : "—";
}
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
@@ -195,12 +236,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
export function formatDateTime(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
export function formatDateTime(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
}
export function formatDateTimeSec(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
export function formatDateTimeSec(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : "";
}
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
+12 -8
View File
@@ -12,12 +12,16 @@ describe("a job's result", () => {
it("names each count, and a phase's own counts inside it", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { new: 3, failed: 0 })).toBe("új videó: 3 · sikertelen: 0");
expect(jobResultText(t, { new: 3, failed: 0 }, "hu")).toBe("új videó: 3 · sikertelen: 0");
expect(
jobResultText(t, {
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
}),
jobResultText(
t,
{
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
},
"hu",
),
).toBe(
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
@@ -26,15 +30,15 @@ describe("a job's result", () => {
it("names a skip reason; an upstream error stays its own text", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" })).toBe(
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
);
expect(jobResultText(t, { error: "Plex returned 500" })).toBe("hiba: Plex returned 500");
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
});
it("shows a key or word it does not know as it is, readable", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" })).toBe(
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" }, "hu")).toBe(
"brand new count: 3 · kihagyva: moon is full",
);
});
+3 -3
View File
@@ -22,16 +22,16 @@ export function parseSummary(summary: string | null | undefined): JobResult | nu
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
export function jobResultText(t: TFunction, result: JobResult): string {
export function jobResultText(t: TFunction, result: JobResult, lang: string): string {
return Object.entries(result)
.map(([key, value]) => {
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") });
if (typeof value === "number") return `${label}: ${formatNumber(value)}`;
if (typeof value === "number") return `${label}: ${formatNumber(value, lang)}`;
if (typeof value === "string")
return key === "error"
? `${label}: ${value}`
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
return `${label} (${jobResultText(t, value)})`;
return `${label} (${jobResultText(t, value, lang)})`;
})
.join(" · ");
}
+45 -8
View File
@@ -2,18 +2,19 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms
// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to
// be formatted by hand, and where it stands down.
const RULE = "local/date-format";
// `local/locale-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date, a number and an
// alphabetical order are in the UI language, built in lib/format.ts (text compared in lib/collate.ts),
// and a component takes the language off useTranslation, never the i18n singleton. This pins that the
// rule fires on the ways each used to be done by hand, and where it stands down.
const RULE = "local/locale-format";
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a hand-formatted date", () => {
it("is refused as toLocale*String or an Intl date formatter", async () => {
describe("a hand-formatted date or number", () => {
it("is refused as toLocale*String or an Intl date or number formatter", async () => {
expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1);
expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1);
expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1);
@@ -21,12 +22,48 @@ describe("a hand-formatted date", () => {
await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"),
).toBe(1);
expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1);
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(1);
});
it("is left alone in lib/format.ts, in a test and for a number format", async () => {
it("is left alone in lib/format.ts and in a test", async () => {
const src = "export const g = (d: Date) => d.toLocaleString('en');\n";
expect(await hits(src, "src/lib/format.ts")).toBe(0);
expect(await hits(src, "src/lib/format.test.ts")).toBe(0);
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(0);
expect(await hits("export const h = new Intl.NumberFormat('en');\n", "src/lib/format.ts")).toBe(
0,
);
});
});
describe("a hand-rolled alphabetical order", () => {
it("is refused as localeCompare, or an Intl.Collator outside lib/collate.ts", async () => {
expect(await hits("export const s = (a: string, b: string) => a.localeCompare(b);\n")).toBe(1);
expect(
await hits(
"export const s = (a: string, b: string) => a.localeCompare(b);\n",
"src/lib/x.ts",
),
).toBe(1);
expect(await hits("export const c = new Intl.Collator('hu');\n")).toBe(1);
expect(await hits("export const c = new Intl.Collator('hu');\n", "src/lib/collate.ts")).toBe(0);
});
});
describe("the language in a component", () => {
it("is refused off the i18n singleton", async () => {
const src = 'import i18n from "../i18n";\nexport const L = () => <b>{i18n.language}</b>;\n';
expect(await hits(src)).toBe(1);
const next =
'import i18next from "i18next";\nexport const L = () => <b>{i18next.language}</b>;\n';
expect(await hits(next)).toBe(1);
});
it("is taken from useTranslation, and the singleton stays usable outside components", async () => {
const hook =
'import { useTranslation } from "react-i18next";\n' +
"export const L = () => {\n const { i18n } = useTranslation();\n return <b>{i18n.language}</b>;\n};\n";
expect(await hits(hook)).toBe(0);
const lib = 'import i18n from "../i18n";\nexport const lang = () => i18n.language;\n';
expect(await hits(lib, "src/lib/__probe.ts")).toBe(0);
});
});
+9 -11
View File
@@ -46,17 +46,17 @@ describe("applyView", () => {
];
it("returns the stored order untouched with no sort and no grouping", () => {
expect(applyView(rows, columns, null, false)).toBe(rows);
expect(applyView(rows, columns, null, false, "en")).toBe(rows);
});
it("sorts by the named column", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false, "en"))).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
]);
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false, "en"))).toEqual([
"delta",
"charlie",
"bravo",
@@ -65,7 +65,7 @@ describe("applyView", () => {
});
it("groups by channel, ordering the groups by name and sorting within each", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true, "en"))).toEqual([
"alpha", // Alpha
"bravo", // Alpha
"charlie", // Beta
@@ -74,7 +74,7 @@ describe("applyView", () => {
});
it("reverses the GROUP order too when the sort is descending", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true, "en"))).toEqual([
"delta", // Beta first, because the direction flips the group order as well
"charlie",
"bravo", // then Alpha
@@ -83,7 +83,7 @@ describe("applyView", () => {
});
it("groups without a sort by keeping each group's stored order", () => {
expect(titles(applyView(rows, columns, null, true))).toEqual([
expect(titles(applyView(rows, columns, null, true, "en"))).toEqual([
"alpha",
"bravo",
"delta",
@@ -93,11 +93,9 @@ describe("applyView", () => {
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
"short",
"long",
"live",
]);
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false, "en"))).toEqual(
["short", "long", "live"],
);
});
});
+4 -2
View File
@@ -1,4 +1,5 @@
import { sortRows, type SortState } from "./columnSort";
import { compareText } from "./collate";
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
@@ -21,8 +22,9 @@ export function applyView<T extends Grouped>(
columns: Sortable<T>[],
sort: SortState,
group: boolean,
lang: string,
): T[] {
const sorted = sortRows(rows, columns, sort);
const sorted = sortRows(rows, columns, sort, lang);
if (!group) return sorted;
const groups = new Map<string, T[]>();
for (const v of sorted) {
@@ -32,7 +34,7 @@ export function applyView<T extends Grouped>(
else groups.set(k, [v]);
}
const sign = sort?.dir === "desc" ? -1 : 1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
const keys = [...groups.keys()].sort((a, b) => sign * compareText(a, b, lang));
return keys.flatMap((k) => groups.get(k)!);
}
+3 -3
View File
@@ -17,7 +17,7 @@ import {
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { plexLang } from "../i18n";
import i18n, { plexLang } from "../i18n";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -264,7 +264,7 @@ export function usePlexProgress(opts: {
// checkpoint (the resume point when leaving).
const settle = () => {
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang());
const key = qk.plexItem(id, plexLang(i18n.language));
if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the
// rollback restored, and the catch below has already dropped this entry, so the next open
@@ -311,7 +311,7 @@ export function usePlexProgress(opts: {
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang()) });
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
});
};
return () => {

Some files were not shown because too many files have changed in this diff Show More