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 - **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. 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. - **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; 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" / 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. "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", 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 "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". 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. - A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
## Anti-patterns ## 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 | 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 | 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-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 | 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 | 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 | | 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 // E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale // UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts // `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a // bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
// number goes through formatNumber, which the same rule covers). // alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
const dateFormat = { // 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: { meta: {
type: "problem", type: "problem",
messages: { 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) { create(context) {
const path = context.filename.split("\\").join("/"); const path = context.filename.split("\\").join("/");
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's if (/\.test\.tsx?$/.test(path)) return {};
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date. // lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {}; // 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 { return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"( "CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node, 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, 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, "jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled, "focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive, "button-primitive": buttonPrimitive,
"date-format": dateFormat, "locale-format": localeFormat,
"input-primitive": inputPrimitive, "input-primitive": inputPrimitive,
"surface-role": surfaceRole, "surface-role": surfaceRole,
"literal-a11y-text": literalA11yText, "literal-a11y-text": literalA11yText,
@@ -477,7 +510,7 @@ export default tseslint.config(
"local/jsx-comment-text": "error", "local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error", "local/focus-safe-disabled": "error",
"local/button-primitive": "error", "local/button-primitive": "error",
"local/date-format": "error", "local/locale-format": "error",
"local/input-primitive": "error", "local/input-primitive": "error",
"local/surface-role": "error", "local/surface-role": "error",
"local/literal-a11y-text": "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 { Button } from "./ui/Button";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string { function fmtDate(d: string | null | undefined, lang: string): string {
if (!d) return "—"; if (!d) return "—";
const t = new Date(d); 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 // About dialog: app + build + schema versions (frontend/backend/database), plus a
@@ -23,7 +23,8 @@ export default function About({
onClose: () => void; onClose: () => void;
onOpenReleaseNotes: () => void; onOpenReleaseNotes: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const { data } = useQuery({ const { data } = useQuery({
queryKey: qk.version(), queryKey: qk.version(),
queryFn: api.version, queryFn: api.version,
@@ -40,7 +41,7 @@ export default function About({
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…", data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
], ],
[t("about.database"), data?.db_revision ?? "…"], [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 ( return (
+3 -2
View File
@@ -55,7 +55,8 @@ export default function AccountPanel({
user: AdminUserRow; user: AdminUserRow;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -182,7 +183,7 @@ export default function AccountPanel({
label={t("users.account.lastLogin")} label={t("users.account.lastLogin")}
value={ value={
a.last_login_at a.last_login_at
? formatDateTime(a.last_login_at) ? formatDateTime(a.last_login_at, lang)
: a.is_demo : a.is_demo
? t("users.account.lastLoginDemo") ? t("users.account.lastLoginDemo")
: t("users.account.lastLoginNone") : t("users.account.lastLoginNone")
+6 -4
View File
@@ -74,7 +74,8 @@ function toQuery(state: LogsUrlState, q: string): LogQuery {
} }
export default function AdminLogs() { export default function AdminLogs() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [state, setState] = useState<LogsUrlState>(() => const [state, setState] = useState<LogsUrlState>(() =>
logsParamsToState(new URLSearchParams(window.location.search)), logsParamsToState(new URLSearchParams(window.location.search)),
@@ -406,7 +407,7 @@ export default function AdminLogs() {
level={row.level} level={row.level}
event={row.event ?? "—"} event={row.event ?? "—"}
msg={row.msg} msg={row.msg}
title={formatDateTimeSec(row.ts)} title={formatDateTimeSec(row.ts, lang)}
open={open === row.id} open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)} onToggle={() => setOpen(open === row.id ? null : row.id)}
> >
@@ -468,14 +469,15 @@ function RowDetail({
onRequest: (id: string) => void; onRequest: (id: string) => void;
onJob: (id: number) => void; onJob: (id: number) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const pivotCls = const pivotCls =
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid"; "font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
return ( return (
<div className="flex flex-col gap-2"> <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"> <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> <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> <dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono"> <dd className="font-mono">
{row.source} · {row.logger} {row.source} · {row.logger}
@@ -93,7 +93,7 @@ describe("the last sign-in column (E22 S1)", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
expect(await screen.findByText(relativeTime(at))).toBeTruthy(); expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2); expect(screen.getAllByText("—")).toHaveLength(2);
}); });
}); });
+13 -9
View File
@@ -103,7 +103,8 @@ export default function AdminUsers() {
} }
function UsersRoles({ me }: { me: Me }) { function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
@@ -336,14 +337,14 @@ function UsersRoles({ me }: { me: Me }) {
<Tooltip <Tooltip
hint={ hint={
u.last_login_at 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 : u.is_demo
? t("users.roles.lastLoginDemo") ? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone") : t("users.roles.lastLoginNone")
} }
> >
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums"> <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> </span>
</Tooltip> </Tooltip>
</div> </div>
@@ -440,7 +441,8 @@ function PendingAccessRowView({
onActivate: () => void; onActivate: () => void;
onResend: () => void; onResend: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
return ( return (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}> <div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -449,7 +451,7 @@ function PendingAccessRowView({
{row.created_at && ( {row.created_at && (
<span> <span>
{t("users.pendingAccess.signedUp", { {t("users.pendingAccess.signedUp", {
time: formatDateTime(row.created_at), time: formatDateTime(row.created_at, lang),
})} })}
</span> </span>
)} )}
@@ -536,6 +538,7 @@ function InviteLangPicker({
function AdminInvites() { function AdminInvites() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const me = useMe(); const me = useMe();
@@ -640,7 +643,7 @@ function AdminInvites() {
: "settings.invites.inviteConfirm", : "settings.invites.inviteConfirm",
{ {
email: inv.email, email: inv.email,
time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()), time: formatDateTime(inv.invited_at ?? new Date(0).toISOString(), lang),
}, },
)} )}
</p> </p>
@@ -744,7 +747,7 @@ function AdminInvites() {
hint={ hint={
i.invited_at i.invited_at
? t("settings.invites.invitedAt", { ? t("settings.invites.invitedAt", {
time: formatDateTime(i.invited_at), time: formatDateTime(i.invited_at, lang),
}) })
: t("settings.invites.inviteHint") : t("settings.invites.inviteHint")
} }
@@ -931,7 +934,8 @@ function InviteRow({
onDelete: () => void; onDelete: () => void;
busy: boolean; busy: boolean;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
return ( return (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}> <div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -939,7 +943,7 @@ function InviteRow({
{inv.requested_at && ( {inv.requested_at && (
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("settings.invites.requested", { {t("settings.invites.requested", {
time: formatDateTime(inv.requested_at), time: formatDateTime(inv.requested_at, lang),
})} })}
</div> </div>
)} )}
+8 -4
View File
@@ -13,12 +13,14 @@ import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns"; import { auditColumns } from "./auditColumns";
import { notifyActionError } from "../lib/actionErrors"; 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 // 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 // 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. // DataTable; the API returns the most recent window (capped) — the log stays small by design.
export default function AuditLog() { export default function AuditLog() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
@@ -26,9 +28,11 @@ export default function AuditLog() {
const rows = useMemo(() => data?.items ?? [], [data]); const rows = useMemo(() => data?.items ?? [], [data]);
const actionOptions = useMemo(() => { const actionOptions = useMemo(() => {
const present = Array.from(new Set(rows.map((r) => r.action))).sort(); // By what the reader sees — the named action, in their alphabet — not by the code.
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })); return Array.from(new Set(rows.map((r) => r.action)))
}, [rows, t]); .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 columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
const clear = useMutation({ const clear = useMutation({
+1
View File
@@ -104,6 +104,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
const account = meRef.current; const account = meRef.current;
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
const lang = account?.preferences?.language; 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); 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. // 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); setReadyFor(identity);
+5 -4
View File
@@ -41,7 +41,8 @@ export default function ChannelDiscovery({
onOpenWizard: () => void; onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
@@ -148,7 +149,7 @@ export default function ChannelDiscovery({
hideInCard: true, hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />, render: (c) => <AboutCell text={c.description} channelId={c.id} />,
}, },
subsColumn<DiscoveredChannel>(t), subsColumn<DiscoveredChannel>(t, lang),
{ {
key: "videos", key: "videos",
header: t("channels.discovery.cols.totalVideos"), header: t("channels.discovery.cols.totalVideos"),
@@ -157,7 +158,7 @@ export default function ChannelDiscovery({
sortable: true, sortable: true,
sortValue: (c) => c.video_count ?? -1, sortValue: (c) => c.video_count ?? -1,
render: (c) => ( 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 // 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. // copy+sort the whole list every render for a result nothing reads.
const isCards = layout === "cards"; const isCards = layout === "cards";
const sortedRows = isCards ? sortRows(rows, columns, sort) : rows; const sortedRows = isCards ? sortRows(rows, columns, sort, lang) : rows;
const { const {
paged: pagedRows, paged: pagedRows,
pagerProps, pagerProps,
+4 -3
View File
@@ -35,7 +35,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView"; 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 { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -70,6 +70,7 @@ export default function ChannelPage({
onShowInFeed: () => void; onShowInFeed: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const { closeChannel } = useNavigationActions(); const { closeChannel } = useNavigationActions();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -281,7 +282,7 @@ export default function ChannelPage({
const metaParts = [ const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null, ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null ch?.subscriber_count != null
? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) ? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) })
: null, : null,
// Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the // 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 // 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 }) : t("channel.videoCount", { count: ch.video_count })
: null, : null,
ch?.total_view_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, : null,
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful // 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. // 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). // (i18n/index.ts calls i18n.use(initReactI18next) at import time).
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api"; import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format"; import { formatEta, formatNumber } from "../lib/format";
import type { TFunction } from "i18next";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -74,7 +78,7 @@ describe("ChannelStatsPopover", () => {
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy(); expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
expect(screen.getByText(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", () => { 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 // 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 // formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
// locale/impl-agnostic (expected === what the component renders). // 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 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 // with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
// are channel-catalog figures). // are channel-catalog figures).
export default function ChannelStatsPopover({ status }: { status: MyStatus }) { export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({ const pop = useAnchoredPopover({
open, open,
@@ -58,7 +59,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{ {
icon: Clock, icon: Clock,
label: t("channels.stats.left"), 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 }), hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
}, },
] ]
@@ -66,13 +67,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{ {
icon: Film, icon: Film,
label: t("channels.stats.myVideos"), label: t("channels.stats.myVideos"),
value: formatNumber(status.my_videos), value: formatNumber(status.my_videos, lang),
hint: t("channels.stats.myVideosHint"), hint: t("channels.stats.myVideosHint"),
}, },
{ {
icon: Zap, icon: Zap,
label: t("channels.stats.quotaLeft"), label: t("channels.stats.quotaLeft"),
value: formatNumber(status.quota_remaining_today), value: formatNumber(status.quota_remaining_today, lang),
hint: t("channels.stats.quotaLeftHint"), hint: t("channels.stats.quotaLeftHint"),
}, },
]; ];
+4 -2
View File
@@ -107,7 +107,8 @@ export default function Channels() {
const { openChannel: onViewChannel, setPage } = useNavigationActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters(); const feedFilters = useFeedFilters();
const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App // 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({ const columns = buildChannelColumns({
t, t,
lang: i18n.language,
prioOptions, prioOptions,
userTags, userTags,
bumpPriority, 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 // 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. // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
const sortedChannels = isCards const sortedChannels = isCards
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort) ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang)
: syncedChannels; : syncedChannels;
const { const {
paged: pagedCards, 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. */ /** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() { function SessionHistory() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions }); const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? []; const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data) if (q.isError && !q.data)
@@ -394,7 +395,7 @@ function SessionHistory() {
key={s.id} key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" 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"> <span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)} {s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span> </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. // 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. // Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() { export default function DiagnosticsSettings() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const me = useMe(); const me = useMe();
const qc = useQueryClient(); const qc = useQueryClient();
const state = me.diagnostics; const state = me.diagnostics;
@@ -173,8 +174,8 @@ export default function DiagnosticsSettings() {
level: levelName(current), level: levelName(current),
time: time:
remaining.h > 0 remaining.h > 0
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}` ? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}`
: formatUnit(remaining.m, "minute"), : formatUnit(remaining.m, "minute", lang),
})} })}
</span> </span>
</span> </span>
@@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({ vi.mock("../lib/useLiveQuery", () => ({
@@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({ 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. // that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean; 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). // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
const { openWizard: onOpenWizard } = useWizard(); const { openWizard: onOpenWizard } = useWizard();
// Same list the panel's badge counts — see lib/useActiveFilters. // Same list the panel's badge counts — see lib/useActiveFilters.
@@ -439,6 +440,7 @@ export default function Feed({
withOverrides(ytLoaded), withOverrides(ytLoaded),
ytSort.key as SortKey, ytSort.key as SortKey,
ytSort.dir, ytSort.dir,
lang,
); );
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError = const ytError =
+3 -1
View File
@@ -31,12 +31,14 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key, opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" }, i18n: real,
}), }),
}; };
}); });
+15 -8
View File
@@ -30,7 +30,13 @@ import {
} from "../lib/api"; } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent"; 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 { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -502,7 +508,8 @@ function Hero({
onReroll: () => void; onReroll: () => void;
rerolling: boolean; 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 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. // the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const { menu, items, panels, plexAdd } = useHomeCardMenu({ const { menu, items, panels, plexAdd } = useHomeCardMenu({
@@ -533,12 +540,12 @@ function Hero({
const plex = card.plex; const plex = card.plex;
const video = card.video; const video = card.video;
const facts = plex const facts = plex
? [plex.year, formatRuntime(card.duration_seconds)] ? [plex.year, formatRuntime(card.duration_seconds, lang)]
: video : video
? // The channel is the subtitle already. ? // The channel is the subtitle already.
[ [
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`, video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
relativeTime(video.published_at), relativeTime(video.published_at, lang),
] ]
: []; : [];
const factLine = facts.filter(Boolean).join(" · "); const factLine = facts.filter(Boolean).join(" · ");
@@ -631,7 +638,7 @@ function Hero({
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>} {factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden /> <Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{plex.rating.toFixed(1)} {formatDecimal(plex.rating, lang)}
</span> </span>
)} )}
</p> </p>
@@ -692,7 +699,7 @@ function readSavedQuestion(): SavedQuestion | null {
} }
export default function Home() { export default function Home() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const { setJumpTo } = usePlexActions(); const { setJumpTo } = usePlexActions();
@@ -722,7 +729,7 @@ export default function Home() {
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null); 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. // 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({ const q = useQuery({
queryKey: qk.discover(budget, lang), queryKey: qk.discover(budget, lang),
queryFn: () => api.discover(budget, 0, lang), queryFn: () => api.discover(budget, 0, lang),
+6 -4
View File
@@ -114,6 +114,7 @@ function ComposerBody({
total: number; total: number;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const pageName = (page: string) => { const pageName = (page: string) => {
const def = moduleDef(page as Page); const def = moduleDef(page as Page);
@@ -304,8 +305,8 @@ function ComposerBody({
<div ref={root} className="flex flex-col gap-4"> <div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
{t("recorder.composer.meta", { {t("recorder.composer.meta", {
start: formatDateTimeSec(started.toISOString()), start: formatDateTimeSec(started.toISOString(), lang),
minutes: formatUnit(minutes, "minute"), minutes: formatUnit(minutes, "minute", lang),
level: t(`settings.diagnostics.levels.${session.level}`), level: t(`settings.diagnostics.levels.${session.level}`),
})} })}
</p> </p>
@@ -607,7 +608,8 @@ function Attachments({
onReview: (source: Source) => void; onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>; reviewed: MutableRefObject<Source | null>;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const shots = shotsQ.data?.items ?? []; const shots = shotsQ.data?.items ?? [];
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays. // 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"> <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-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> </div>
{!readOnly && ( {!readOnly && (
<IconButton <IconButton
@@ -8,7 +8,10 @@ import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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) => ({ const conversation = (id: number, name: string) => ({
@@ -23,7 +23,7 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()), ...(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("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useLiveQuery", () => ({ vi.mock("../lib/useLiveQuery", () => ({
@@ -507,6 +507,7 @@ function NotificationRow({
onOpenScheduler: () => void; onOpenScheduler: () => void;
onOpenDownloads: () => void; onOpenDownloads: () => void;
}) { }) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles. // 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) const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
? n.data!.videos ? n.data!.videos
@@ -535,7 +536,7 @@ function NotificationRow({
job, job,
}); });
const result = d.result ?? parseSummary(d.summary); 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) { } else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name // 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. // 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 { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) => t: (key: string, o?: { name?: string }) =>
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key, key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
}), }),
+6 -5
View File
@@ -44,7 +44,7 @@ import {
channelYouTubeUrl, channelYouTubeUrl,
formatDate, formatDate,
formatDuration, formatDuration,
formatViews, formatCompact,
relativeTime, relativeTime,
} from "../lib/format"; } from "../lib/format";
import { renderDescription } from "./descriptionLinks"; import { renderDescription } from "./descriptionLinks";
@@ -119,6 +119,7 @@ export default function PlayerModal({
// icon next to the name keeps the open-on-YouTube behaviour. // icon next to the name keeps the open-on-YouTube behaviour.
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
const qc = useQueryClient(); const qc = useQueryClient();
const descFade = useScrollFade(); const descFade = useScrollFade();
@@ -899,12 +900,12 @@ export default function PlayerModal({
·{" "} ·{" "}
{t("player.views", { {t("player.views", {
count: active.view_count, count: active.view_count,
formattedCount: formatViews(active.view_count), formattedCount: formatCompact(active.view_count, lang),
})} })}
</span> </span>
)} )}
<span> <span>
· {relativeTime(active.published_at)} · {relativeTime(active.published_at, lang)}
{active.published_at && ` · ${fullDate(active.published_at)}`} {active.published_at && ` · ${fullDate(active.published_at)}`}
</span> </span>
{active.duration_seconds != null && ( {active.duration_seconds != null && (
@@ -924,13 +925,13 @@ export default function PlayerModal({
·{" "} ·{" "}
{t("player.views", { {t("player.views", {
count: detail.data.view_count, count: detail.data.view_count,
formattedCount: formatViews(detail.data.view_count), formattedCount: formatCompact(detail.data.view_count, lang),
})} })}
</span> </span>
)} )}
{detail.data?.published_at && ( {detail.data?.published_at && (
<span> <span>
· {relativeTime(detail.data.published_at)} ·{" "} · {relativeTime(detail.data.published_at, lang)} ·{" "}
{fullDate(detail.data.published_at)} {fullDate(detail.data.published_at)}
</span> </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 // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection. // list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists(); const { selectedId } = usePlaylists();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const plActions = usePlaylistActions(); const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@@ -391,8 +392,8 @@ export default function Playlists() {
const filterActive = Object.values(filters).some(isActive); const filterActive = Object.values(filters).some(isActive);
const shown = useMemo( const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy), () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
[items, columns, filters, viewSort, groupBy], [items, columns, filters, viewSort, groupBy, lang],
); );
// Read by the drag announcements, which must report the CURRENT positions without making the // Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext. // 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"> <div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted"> <span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })} {t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span> </span>
{(items.length > 1 || viewActive) && ( {(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 { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; 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. // box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists(); const { search, selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions(); const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
const plSortOptions: SortOption[] = [ const plSortOptions: SortOption[] = [
@@ -123,7 +125,7 @@ export default function PlaylistsRail({
if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1;
switch (plSort.key) { switch (plSort.key) {
case "name": case "name":
return sign * plName(a).localeCompare(plName(b)); return sign * compareText(plName(a), plName(b), lang);
case "count": case "count":
return sign * (a.item_count - b.item_count); return sign * (a.item_count - b.item_count);
case "duration": case "duration":
@@ -134,7 +136,7 @@ export default function PlaylistsRail({
return 0; return 0;
} }
}); });
}, [playlists, plSort, plName]); }, [playlists, plSort, plName, lang]);
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo( const visiblePlaylists = useMemo(
@@ -366,7 +368,7 @@ export default function PlaylistsRail({
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })} {t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 && {pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`} ` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
</div> </div>
</div> </div>
</Pressable> </Pressable>
+3 -3
View File
@@ -93,7 +93,7 @@ const BACKABLE_VIEWS: readonly string[] = [
]; ];
export default function PlexBrowse() { export default function PlexBrowse() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the // 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 // 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); const dq = useDebounced(q.trim(), 350);
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so // 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. // 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 // 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). // genre-clear effect (below) can settle first (see the effect for why).
const [committedLang, setCommittedLang] = useState(lang); 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 // 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. // 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 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. // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
const fieldState = healPlexField(field, scope); const fieldState = healPlexField(field, scope);
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search // 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 })); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
@@ -67,7 +67,7 @@ export default function PlexCollectionView({
onRestartCard: (card: PlexCard) => void; onRestartCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// One menu for the member grid (see ui/ContextMenu). // 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 // 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 // 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. // 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 detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
const q = useQuery({ const q = useQuery({
queryKey: detailKey, 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). // 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 scope = items.some((c) => c.type === "show") ? "show" : "movie";
const sortState = healPlexSort(sortPref, scope); 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 // 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 — // 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 // 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.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
d?.content_rating, d?.content_rating,
d && t("plex.browse.items", { count: d.child_count ?? items.length }), 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[]; ].filter(Boolean) as string[];
return ( return (
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, opts?: Record<string, unknown>) => t: (key: string, opts?: Record<string, unknown>) =>
opts?.title ? `${key}:${opts.title}` : key, opts?.title ? `${key}:${opts.title}` : key,
}), }),
+2 -2
View File
@@ -108,9 +108,9 @@ export default function PlexDiscover({
onGenre: (genre: string) => void; onGenre: (genre: string) => void;
onSeeAllRecent: () => void; onSeeAllRecent: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); 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 // 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). // query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
const { filters } = usePlex(); const { filters } = usePlex();
@@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, opts?: Record<string, unknown>) => t: (key: string, opts?: Record<string, unknown>) =>
opts?.name ? `${key}:${opts.name}` : key, 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 { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api"; 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 { notifyActionError } from "../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; 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 // 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. // 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 // 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 // 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 // declared during render is a NEW component type on every render — React unmounts and remounts the
@@ -70,7 +51,8 @@ function Score({
filterable: boolean; filterable: boolean;
onFilterRating?: (score: number) => void; onFilterRating?: (score: number) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const shown = formatDecimal(score, i18n.language);
return filterable && onFilterRating ? ( return filterable && onFilterRating ? (
<Pressable <Pressable
onClick={() => onFilterRating(Math.floor(score))} onClick={() => onFilterRating(Math.floor(score))}
@@ -78,12 +60,12 @@ function Score({
title={t("plex.info.filterRating", { n: Math.floor(score) })} title={t("plex.info.filterRating", { n: Math.floor(score) })}
> >
<Star className="h-3.5 w-3.5 fill-current" /> <Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)} {shown}
</Pressable> </Pressable>
) : ( ) : (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" /> <Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)} {shown}
</span> </span>
); );
} }
@@ -110,7 +92,7 @@ function RatingsRow({
* theme's dark gold would sit on the player's black (2.6:1, measured). */ * theme's dark gold would sit on the player's black (2.6:1, measured). */
onDark?: boolean; onDark?: boolean;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score 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. // 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 ${ 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}> <span className={chipCls}>
<Score score={tmdbRating} filterable={false} /> <Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal"> <span className="text-2xs font-normal">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
</span> </span>
</span> </span>
)} )}
@@ -171,7 +153,8 @@ function FactsPanel({
detail: PlexItemDetail; detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => void; onFilter?: (patch: Partial<PlexFilters>) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = detail.tmdb; const tmdb = detail.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = []; const rows: { key: string; label: string; node: ReactNode }[] = [];
@@ -195,7 +178,7 @@ function FactsPanel({
rows.push({ rows.push({
key: "runtime", key: "runtime",
label: t("plex.info.facts.runtime"), label: t("plex.info.facts.runtime"),
node: formatRuntime(detail.duration_seconds), node: formatRuntime(detail.duration_seconds, lang),
}); });
const cert = detail.content_rating; const cert = detail.content_rating;
if (cert) if (cert)
@@ -209,9 +192,17 @@ function FactsPanel({
), ),
}); });
if (tmdb?.budget) 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) 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) if (studios.length)
rows.push({ rows.push({
key: "studios", key: "studios",
@@ -372,7 +363,8 @@ export default function PlexInfo({
onPlayItem, onPlayItem,
library, library,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs: // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
@@ -601,7 +593,7 @@ export default function PlexInfo({
</Filterable> </Filterable>
)} )}
{detail.duration_seconds ? ( {detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span> <span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null} ) : null}
{detail.studio && ( {detail.studio && (
<Filterable <Filterable
@@ -25,7 +25,12 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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(); const plexPerson = vi.fn();
@@ -116,7 +121,8 @@ describe("PlexPersonView", () => {
// E30: TMDB's English department, named through the vocab (key-echo i18n here). // E30: TMDB's English department, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.department.Acting")).toBeTruthy(); expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
expect(screen.getByText("A celebrated actor.")).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(/plex\.person\.aka/)).toBeTruthy();
expect(screen.getByText(/Janie D/)).toBeTruthy(); expect(screen.getByText(/Janie D/)).toBeTruthy();
// "Known for" rail renders its card. // "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 qc = useQueryClient();
const [id, setId] = useState(itemId); const [id, setId] = useState(itemId);
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid. // 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({ const detailQ = useQuery({
queryKey: qk.plexItem(id, lang), queryKey: qk.plexItem(id, lang),
queryFn: () => api.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); seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
flashOsd( flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", { 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; return;
@@ -999,7 +999,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
} }
toggleControls(); 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 // 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) => ({ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()), ...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }), useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }),
})); }));
import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { PlexPlayerSettings } from "./PlexPlayerSettings";
@@ -84,7 +84,7 @@ export function PlexPlayerSettings(props: {
min={-10} min={-10}
max={10} max={10}
step={0.1} step={0.1}
suffix="s" seconds
onChange={(v) => patchPrefs({ subOffset: v })} onChange={(v) => patchPrefs({ subOffset: v })}
onReset={() => patchPrefs({ subOffset: 0 })} onReset={() => patchPrefs({ subOffset: 0 })}
/> />
@@ -94,7 +94,7 @@ export function PlexPlayerSettings(props: {
min={-5} min={-5}
max={5} max={5}
step={0.1} step={0.1}
suffix="s" seconds
onChange={changeAudioOffset} onChange={changeAudioOffset}
onReset={() => changeAudioOffset(0)} onReset={() => changeAudioOffset(0)}
/> />
@@ -109,7 +109,7 @@ export function PlexPlayerSettings(props: {
min={5} min={5}
max={30} max={30}
step={1} step={1}
suffix="s" seconds
onChange={(v) => patchPrefs({ seekStep: v })} onChange={(v) => patchPrefs({ seekStep: v })}
/> />
<Slider <Slider
@@ -118,7 +118,7 @@ export function PlexPlayerSettings(props: {
min={0.1} min={0.1}
max={5} max={5}
step={0.1} step={0.1}
suffix="s" seconds
onChange={(v) => patchPrefs({ fineSeekStep: v })} onChange={(v) => patchPrefs({ fineSeekStep: v })}
/> />
<Toggle <Toggle
@@ -148,7 +148,7 @@ export function PlexPlayerSettings(props: {
min={0} min={0}
max={10} max={10}
step={1} step={1}
suffix="s" seconds
onChange={(v) => patchPrefs({ autoSkipDelay: v })} 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 { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => {
...(await importOriginal<typeof import("react-i18next")>()), const real = (await import("i18next")).default;
useTranslation: () => ({ return {
t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), ...(await importOriginal<typeof import("react-i18next")>()),
i18n: { language: "en" }, 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) })); const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) }));
vi.mock("../lib/notifications", async (importOriginal) => ({ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()), ...(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". */ /** A title's media-info page, from its menu's "Details". */
onInfoCard?: (card: PlexCard) => void; onInfoCard?: (card: PlexCard) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@@ -755,7 +756,7 @@ export default function PlexPlaylistView({
{items.length > 0 && ( {items.length > 0 && (
<div className="mt-0.5 text-sm text-muted"> <div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })} {t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
</div> </div>
)} )}
</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) { 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 // 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 // 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. // 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 // 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. // never refetches. collectionMode stays IN the key: any/all changes the result set.
const { collectionTitles: _cts, ...facetKey } = filters; 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({ const facetsQ = useQuery({
queryKey: qk.plexFacets(scope, show, facetKey, lang), queryKey: qk.plexFacets(scope, show, facetKey, lang),
queryFn: () => api.plexFacets(scope, filters, show, lang), queryFn: () => api.plexFacets(scope, filters, show, lang),
+4 -1
View File
@@ -38,7 +38,8 @@ export function PlexStatsOverlay({
durationRef: RefObject<number>; durationRef: RefObject<number>;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const [rows, setRows] = useState<StatRow[]>([]); const [rows, setRows] = useState<StatRow[]>([]);
useEffect(() => { useEffect(() => {
@@ -87,6 +88,7 @@ export function PlexStatsOverlay({
setRows( setRows(
buildStatRows({ buildStatRows({
lang,
playable, playable,
hwEncoder: hwRef.current, hwEncoder: hwRef.current,
tonemap: tonemapRef.current, tonemap: tonemapRef.current,
@@ -110,6 +112,7 @@ export function PlexStatsOverlay({
const iv = setInterval(sample, 1000); const iv = setInterval(sample, 1000);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [ }, [
lang,
videoRef, videoRef,
hlsRef, hlsRef,
fragMeterRef, fragMeterRef,
@@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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"; import Scheduler from "./Scheduler";
@@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) })); const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) }));
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 })); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
+29 -25
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "../i18n"; import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Activity, Activity,
@@ -47,14 +47,14 @@ function secsUntil(iso: string | null): number | null {
return Math.round((new Date(iso).getTime() - Date.now()) / 1000); return Math.round((new Date(iso).getTime() - Date.now()) / 1000);
} }
function fmtCountdown(s: number): string { function fmtCountdown(t: TFunction, s: number, lang: string): string {
if (s <= 0) return i18n.t("time.eta.now"); if (s <= 0) return t("time.eta.now");
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration. // 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); 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); 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> = { 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 // label and an indeterminate sliver — so every active run is visible, not just the ones
// that report numbers. // that report numbers.
function JobProgress({ p }: { p: SchedulerJob["progress"] }) { 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 phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
const pct = const pct =
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null; 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="truncate">{phase}</span>
<span className="tabular-nums shrink-0"> <span className="tabular-nums shrink-0">
{p?.total != null {p?.total != null
? `${formatNumber(p.current)} / ${formatNumber(p.total)}` ? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}`
: p : p
? formatNumber(p.current) ? formatNumber(p.current, lang)
: ""} : ""}
</span> </span>
</div> </div>
@@ -152,7 +153,8 @@ function JobRow({
/** A run-all in flight: transient, natively disabled. */ /** A run-all in flight: transient, natively disabled. */
runBusy: boolean; 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 // 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 // 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 // refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
@@ -247,17 +249,17 @@ function JobRow({
</div> </div>
<div <div
className="text-2xs text-muted truncate" 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 {job.running
? t("scheduler.runningNow") ? t("scheduler.runningNow")
: job.last_finished : job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
: t("scheduler.neverRun")} : t("scheduler.neverRun")}
{job.last_error ? ( {job.last_error ? (
<span className="text-danger"> · {job.last_error}</span> <span className="text-danger"> · {job.last_error}</span>
) : job.result ? ( ) : job.result ? (
<span> · {jobResultText(t, job.result)}</span> <span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? ( ) : job.last_result ? (
<span> · {job.last_result}</span> <span> · {job.last_result}</span>
) : null} ) : null}
@@ -270,7 +272,7 @@ function JobRow({
) : next != null ? ( ) : next != null ? (
<> <>
<div className="uppercase tracking-wide text-3xs">{t("scheduler.nextRun")}</div> <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; onSave: (batch: number) => void;
saving: boolean; 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 // 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 // the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
// `editing` guard at all. // `editing` guard at all.
@@ -363,7 +366,7 @@ function MaintenanceSettings({
)} )}
<div className="text-2xs text-muted mt-1.5"> <div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", { {t("scheduler.maintenance.batchNote", {
default: formatNumber(m.revalidate_batch_default), default: formatNumber(m.revalidate_batch_default, lang),
})} })}
</div> </div>
</div> </div>
@@ -406,7 +409,8 @@ function Stat({
} }
export default function Scheduler() { export default function Scheduler() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when // 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} icon={Database}
label={t("scheduler.queue.recentPending")} label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")} hint={t("scheduler.queue.recentPendingHint")}
value={formatNumber(data.queue.channels_recent_pending)} value={formatNumber(data.queue.channels_recent_pending, lang)}
/> />
<Stat <Stat
icon={Database} icon={Database}
label={t("scheduler.queue.deepPending")} label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")} hint={t("scheduler.queue.deepPendingHint")}
value={formatNumber(data.queue.channels_deep_pending)} value={formatNumber(data.queue.channels_deep_pending, lang)}
/> />
<Stat <Stat
icon={Clock} icon={Clock}
label={t("scheduler.queue.enrichPending")} label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")} hint={t("scheduler.queue.enrichPendingHint")}
value={formatNumber(data.queue.videos_pending_enrich)} value={formatNumber(data.queue.videos_pending_enrich, lang)}
/> />
<Stat <Stat
icon={Clock} icon={Clock}
label={t("scheduler.queue.shortsPending")} label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")} hint={t("scheduler.queue.shortsPendingHint")}
value={formatNumber(data.queue.videos_pending_shorts)} value={formatNumber(data.queue.videos_pending_shorts, lang)}
/> />
<Stat <Stat
icon={RadioTower} icon={RadioTower}
label={t("scheduler.queue.liveRefresh")} label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")} hint={t("scheduler.queue.liveRefreshHint")}
value={formatNumber(data.queue.videos_live_refresh)} value={formatNumber(data.queue.videos_live_refresh, lang)}
/> />
</div> </div>
</div> </div>
@@ -684,15 +688,15 @@ export default function Scheduler() {
<Gauge className="w-4 h-4 text-muted shrink-0" /> <Gauge className="w-4 h-4 text-muted shrink-0" />
<span> <span>
{t("scheduler.quotaUsed", { {t("scheduler.quotaUsed", {
used: formatNumber(data.quota.used_today), used: formatNumber(data.quota.used_today, lang),
budget: formatNumber(data.quota.daily_budget), budget: formatNumber(data.quota.daily_budget, lang),
})} })}
</span> </span>
<span className="flex-1" /> <span className="flex-1" />
<span className="text-muted text-xs"> <span className="text-muted text-xs">
{t("scheduler.quotaRemaining", { {t("scheduler.quotaRemaining", {
count: data.quota.remaining_today, count: data.quota.remaining_today,
formatted: formatNumber(data.quota.remaining_today), formatted: formatNumber(data.quota.remaining_today, lang),
})} })}
</span> </span>
</div> </div>
+14 -7
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { withActiveAccount } from "../lib/activeAccount"; import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { formatDate, formatDateTime } from "../lib/format"; import { formatDate, formatDateTime, formatUnit } from "../lib/format";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; 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 }) { 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 // Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
// it. The "send test" button below fires immediately. // it. The "send test" button below fires immediately.
const { notif: cfg, setNotif } = prefs; const { notif: cfg, setNotif } = prefs;
@@ -270,7 +271,9 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
<div className="py-1.5 text-sm"> <div className="py-1.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span> <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> </div>
<Slider <Slider
className="mt-1" 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 // 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. // shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() { function PlexWatchSync() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() }); const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false; 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 // 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". // only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled; 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 ( return (
<Section title={t("settings.plexSync.title")}> <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 /** 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. */ * not recognise is something they can see. */
export function AccountHeader({ me }: { me: Me }) { export function AccountHeader({ me }: { me: Me }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
return ( return (
<Section title={t("settings.account.title")}> <Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) {
{!me.is_demo && ( {!me.is_demo && (
<div className="text-xs text-muted" data-testid="account-last-login"> <div className="text-xs text-muted" data-testid="account-last-login">
{me.last_login_at {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")} : t("settings.account.lastLoginNone")}
</div> </div>
)} )}
+6 -2
View File
@@ -22,6 +22,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext"; import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { useTags } from "../lib/useTags"; import { useTags } from "../lib/useTags";
import { compareText } from "../lib/collate";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [ const DATE_PRESETS: { days: number; key: string }[] = [
@@ -47,7 +48,8 @@ export default function Sidebar({
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const isDemo = useMe().is_demo; const isDemo = useMe().is_demo;
const { openModal } = useSavedViews(); const { openModal } = useSavedViews();
const filters = useFeedFilters(); const filters = useFeedFilters();
@@ -76,7 +78,9 @@ export default function Sidebar({
const shown = facetsReady const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list; : 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"); 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. // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) { function Overview({ me }: { me: Me }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here. // The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
const { openWizard } = useWizard(); const { openWizard } = useWizard();
@@ -125,14 +126,14 @@ function Overview({ me }: { me: Me }) {
label={t("stats.sync.fullHistoryEta")} label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })} hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
> >
{formatEta(s.deep_eta_seconds)} {formatEta(t, s.deep_eta_seconds)}
</SettingRow> </SettingRow>
)} )}
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}> <SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{formatNumber(s.my_videos)} {formatNumber(s.my_videos, lang)}
</SettingRow> </SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}> <SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{formatNumber(s.quota_remaining_today)} {formatNumber(s.quota_remaining_today, lang)}
</SettingRow> </SettingRow>
</div> </div>
) : status.isError ? ( ) : status.isError ? (
@@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) {
<> <>
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}> <SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{formatNumber(usage.data.today)} {formatNumber(usage.data.today, lang)}
</SettingRow> </SettingRow>
<SettingRow label={t("stats.sync.last7d")}> <SettingRow label={t("stats.sync.last7d")}>
{formatNumber(usage.data.last_7d)} {formatNumber(usage.data.last_7d, lang)}
</SettingRow> </SettingRow>
<SettingRow label={t("stats.sync.last30d")}> <SettingRow label={t("stats.sync.last30d")}>
{formatNumber(usage.data.last_30d)} {formatNumber(usage.data.last_30d, lang)}
</SettingRow> </SettingRow>
<SettingRow label={t("stats.sync.allTime")}> <SettingRow label={t("stats.sync.allTime")}>
{formatNumber(usage.data.all_time)} {formatNumber(usage.data.all_time, lang)}
</SettingRow> </SettingRow>
</div> </div>
{Object.keys(usage.data.by_action).length > 0 && ( {Object.keys(usage.data.by_action).length > 0 && (
@@ -168,8 +169,8 @@ function Overview({ me }: { me: Me }) {
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([action, units]) => ( .map(([action, units]) => (
<div key={action} className="flex justify-between gap-2"> <div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span> <span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span> <span className="tabular-nums">{formatNumber(units, lang)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -218,7 +219,8 @@ function Overview({ me }: { me: Me }) {
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle. // Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
function SystemStats() { function SystemStats() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [days, setDays] = useState<number>(30); const [days, setDays] = useState<number>(30);
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
@@ -263,7 +265,7 @@ function SystemStats() {
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
options={RANGES.map((d) => ({ options={RANGES.map((d) => ({
value: String(d), value: String(d),
label: t("stats.rangeDays", { d: formatUnit(d, "day") }), label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
}))} }))}
value={String(days)} value={String(days)}
onChange={(v) => setDays(Number(v))} onChange={(v) => setDays(Number(v))}
@@ -290,8 +292,8 @@ function SystemStats() {
<div className={`${SURFACE.card} p-3 mb-4`}> <div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2"> <div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", { {t("stats.dailyTotal", {
d: formatUnit(data.range_days, "day"), d: formatUnit(data.range_days, "day", lang),
units: formatNumber(grandTotal), units: formatNumber(grandTotal, lang),
})} })}
</div> </div>
{data.daily.length === 0 ? ( {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 // 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". // dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer" 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 <div
className="w-full bg-accent rounded-t" className="w-full bg-accent rounded-t"
@@ -333,7 +338,8 @@ function SystemStats() {
} }
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { 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 [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]); const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null; const isSystem = row.user_id === null;
@@ -349,7 +355,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{isSystem ? t("stats.system") : row.email} {isSystem ? t("stats.system") : row.email}
</span> </span>
<span className="text-sm font-semibold tabular-nums shrink-0"> <span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total)} {formatNumber(row.total, lang)}
</span> </span>
</Pressable> </Pressable>
{/* Proportion bar */} {/* 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"> <div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => ( {actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2"> <div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span> <span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units)}</span> <span className="tabular-nums">{formatNumber(units, lang)}</span>
</div> </div>
))} ))}
</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 { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format"; import { formatCompact } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -23,7 +23,8 @@ export default function SyncStatus({
isAdmin: boolean; isAdmin: boolean;
onGoToFullHistory: () => void; onGoToFullHistory: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog` // 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 // 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. // history) so the collapsed chip still tells you when to look.
const needsAttention = data.paused || notFull > 0; 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, data.total_videos,
lang,
)} ${t("header.sync.total")}`; )} ${t("header.sync.total")}`;
return ( return (
<div className="pointer-events-auto shrink-0"> <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" /> <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 && ( {needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" /> <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" /> <Database className="w-3.5 h-3.5 shrink-0" />
<span> <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")} {t("header.sync.yours")}
<span className="opacity-40"> / </span> <span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")} {formatCompact(data.total_videos, lang)} {t("header.sync.total")}
</span> </span>
</div> </div>
{showMain && ( {showMain && (
@@ -11,7 +11,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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", () => ({ vi.mock("../lib/api", () => ({
+2 -2
View File
@@ -23,8 +23,8 @@ export function UpNextCard({
onAdvance: () => void; onAdvance: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = plexLang(); const lang = plexLang(i18n.language);
const nextQ = useQuery({ const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang), queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang), queryFn: () => api.plexItem(nextId, lang),
@@ -19,7 +19,10 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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", () => ({ vi.mock("../lib/api", () => ({
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage }, 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 // 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. // lands in the Library as an already-`done` job, so we invalidate + point the user there.
export default function UploadDialog({ onClose }: { onClose: () => void }) { export default function UploadDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -148,7 +149,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
<> <>
<FileIcon className="w-6 h-6 text-muted" /> <FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span> <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 && ( {tooBig && (
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert"> <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> </p>
)} )}
+11 -4
View File
@@ -17,7 +17,13 @@ import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx"; import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api"; import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format"; import {
formatDate,
formatDuration,
formatNumber,
formatCompact,
relativeTime,
} from "../lib/format";
import { isPlainLeftClick } from "../lib/nav"; import { isPlainLeftClick } from "../lib/nav";
import { endsTextSelection } from "../lib/textSelection"; import { endsTextSelection } from "../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink"; import ChannelNameLink from "./ChannelNameLink";
@@ -391,6 +397,7 @@ function VideoCard({
shelf?: boolean; shelf?: boolean;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
const spec = FEED_VIEW_SPEC[view]; const spec = FEED_VIEW_SPEC[view];
const watched = video.status === "watched"; const watched = video.status === "watched";
@@ -405,14 +412,14 @@ function VideoCard({
// on every line and the tooltip carries the figure regardless. // on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) => const viewsCell = (withWord: boolean) =>
video.view_count != null && ( video.view_count != null && (
<span title={`${formatNumber(video.view_count)} ${t("card.views")}`}> <span title={`${formatNumber(video.view_count, lang)} ${t("card.views")}`}>
{formatViews(video.view_count)} {formatCompact(video.view_count, lang)}
{withWord && ` ${t("card.views")}`} {withWord && ` ${t("card.views")}`}
</span> </span>
); );
const published = ( const published = (
<> <>
{relativeTime(video.published_at)} {relativeTime(video.published_at, lang)}
{video.published_at && ( {video.published_at && (
<> <>
{" · "} {" · "}
+3 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, type Video } from "../lib/api"; 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 { linkify } from "../lib/linkify";
import Avatar from "./ui/Avatar"; import Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
@@ -34,6 +34,7 @@ export default function VideoDetailsAt({
onClose: () => void; onClose: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const pop = useAnchoredPopover({ const pop = useAnchoredPopover({
open: true, open: true,
onOpenChange: (next) => { onOpenChange: (next) => {
@@ -72,7 +73,7 @@ export default function VideoDetailsAt({
const description = detail?.description?.trim(); const description = detail?.description?.trim();
const metaBits = [ 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.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
video.published_at ? formatDate(video.published_at, i18n.language) : null, video.published_at ? formatDate(video.published_at, i18n.language) : null,
].filter(Boolean); ].filter(Boolean);
+1 -1
View File
@@ -317,7 +317,7 @@ export default function WatchPage() {
{meta.title || T.file} {meta.title || T.file}
</h1> </h1>
<div className="text-sm text-muted mt-1"> <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) .filter(Boolean)
.join(" · ")} .join(" · ")}
</div> </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 // 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. // describes. Full detail stays in the hover tooltip.
export function StoredCell({ c }: { c: ManagedChannel }) { export function StoredCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
return ( return (
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums"> <span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
{isFullySynced(c) && ( {isFullySynced(c) && (
@@ -24,7 +25,7 @@ export function StoredCell({ c }: { c: ManagedChannel }) {
/> />
</Tooltip> </Tooltip>
)} )}
{formatNumber(c.stored_videos)} {formatNumber(c.stored_videos, lang)}
</span> </span>
); );
} }
@@ -78,6 +78,7 @@ describe("buildChannelColumns — the row's end", () => {
const noop = () => {}; const noop = () => {};
const cols = buildChannelColumns({ const cols = buildChannelColumns({
t: ((k: string) => k) as never, t: ((k: string) => k) as never,
lang: "en",
prioOptions: [], prioOptions: [],
userTags: [], userTags: [],
bumpPriority: noop, 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 /** 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 * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
* channel row shapes. */ * 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 { return {
key: "subs", key: "subs",
header: t("channels.cols.subs"), header: t("channels.cols.subs"),
@@ -23,7 +26,7 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
sortable: true, sortable: true,
sortValue: (c) => c.subscriber_count ?? -1, sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => ( 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). */ * column block lives outside the page component (its cell components are in channelCells). */
export interface ChannelColumnDeps { export interface ChannelColumnDeps {
t: TFunction; t: TFunction;
/** The UI language, for the numbers — the compiler re-builds the columns when it changes. */
lang: string;
prioOptions: { value: string; label: string }[]; prioOptions: { value: string; label: string }[];
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"]; userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
bumpPriority: (id: string, delta: number) => void; bumpPriority: (id: string, delta: number) => void;
@@ -69,6 +74,7 @@ export interface ChannelColumnDeps {
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] { export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const { const {
t, t,
lang,
prioOptions, prioOptions,
userTags, userTags,
bumpPriority, 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). // The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
render: (c) => <StoredCell c={c} />, render: (c) => <StoredCell c={c} />,
}, },
{ ...subsColumn<ManagedChannel>(t), width: "6rem" }, { ...subsColumn<ManagedChannel>(t, lang), width: "6rem" },
{ {
key: "lastUpload", key: "lastUpload",
header: t("channels.cols.lastUpload"), header: t("channels.cols.lastUpload"),
@@ -165,7 +171,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
sortValue: (c) => c.total_duration_seconds, sortValue: (c) => c.total_duration_seconds,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)} {formatTotalHours(c.total_duration_seconds, lang)}
</span> </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. * every feed load would be a real request for a string almost nobody reads that session.
*/ */
export function useComfortPin(open: boolean): ComfortPin { export function useComfortPin(open: boolean): ComfortPin {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides // 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. // 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); if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({ const shelf = useQuery({
queryKey: qk.comfortPins(plexLang()), queryKey: qk.comfortPins(plexLang(i18n.language)),
queryFn: () => api.comfortPins(plexLang()), queryFn: () => api.comfortPins(plexLang(i18n.language)),
enabled: everOpen, enabled: everOpen,
// The shelf is hand-curated and short; re-asking on every menu open would be noise. // The shelf is hand-curated and short; re-asking on every menu open would be noise.
staleTime: 60_000, staleTime: 60_000,
+6 -5
View File
@@ -160,7 +160,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
} }
export function AdminSystem() { export function AdminSystem() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); 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"> <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.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"), 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]) => ( ].map(([label, val]) => (
<div key={label} className={`${SURFACE.card} p-3`}> <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" 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="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 <IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })} onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
label={t("downloads.admin.editQuota")} label={t("downloads.admin.editQuota")}
@@ -226,7 +227,7 @@ export function AdminSystem() {
<span className="text-muted truncate w-40">{j.user_email}</span> <span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} /> <StatusPill job={j} />
<span className="text-muted w-16 text-right"> <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> </span>
</div> </div>
))} ))}
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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"; import { DownloadRow, IconBtn } from "./downloadRow";
+11 -9
View File
@@ -240,7 +240,8 @@ export function DownloadRow({
more?: ReactNode; more?: ReactNode;
children?: ReactNode; children?: ReactNode;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const running = job.status === "running"; const running = job.status === "running";
const linkTitle = !hasOwnTitle(job) && !!job.source_url; const linkTitle = !hasOwnTitle(job) && !!job.source_url;
const sourceLink = linkTitle ? null : 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> <span className="text-muted uppercase"> · {job.asset.container}</span>
) : null} ) : null}
{job.asset?.size_bytes ? ( {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} ) : null}
{job.asset?.duration_s ? ( {job.asset?.duration_s ? (
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span> <span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
) : null} ) : null}
{job.status === "done" && job.created_at ? ( {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]" />{" "} <CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at)} {relativeTime(job.created_at, lang)}
</span> </span>
) : null} ) : null}
{job.status === "error" ? ( {job.status === "error" ? (
@@ -360,8 +361,8 @@ export function DownloadRow({
</div> </div>
<div className="text-xs text-muted mt-0.5 flex gap-2"> <div className="text-xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span> <span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null} {job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null} {job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
</div> </div>
</> </>
)} )}
@@ -373,7 +374,8 @@ export function DownloadRow({
} }
export function UsageBar() { 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 // 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 // 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 // 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"> <div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span> <span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted"> <span className="text-muted">
{formatBytes(u.footprint_bytes)} {formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
{u.unlimited {u.unlimited
? t("downloads.usage.unlimited") ? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })} : 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 // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime). // collections have no movie runtime).
function collectionMeta(c: PlexCollection, t: TFunction): string { function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string {
const parts: string[] = []; const parts: string[] = [];
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); 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); if (rt) parts.push(rt);
return parts.join(" · "); 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. // 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 }) { export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -223,7 +223,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
navIndex={i} navIndex={i}
title={c.title} title={c.title}
art={c.art} art={c.art}
subtitle={collectionMeta(c, t)} subtitle={collectionMeta(c, t, i18n.language)}
topRight={ topRight={
isIntrinsicReadOnly(c) ? ( isIntrinsicReadOnly(c) ? (
<Lock <Lock
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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"; 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 actual = await importOriginal<typeof import("react-i18next")>();
const t = (key: string, o?: Record<string, unknown>) => const t = (key: string, o?: Record<string, unknown>) =>
o ? `${key}(${Object.values(o).join("|")})` : key; o ? `${key}(${Object.values(o).join("|")})` : key;
return { ...actual, useTranslation: () => ({ t }) }; return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t }) };
}); });
import { PlexPosterCard, SeasonCard } from "./plexCards"; 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 { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { type ReactNode, useRef } from "react"; import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format"; import { formatDecimal, formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail"; import { seasonTitle } from "../lib/plexDetail";
@@ -287,7 +287,8 @@ export function PlexPosterCard({
/** The show's page, beside a `next` episode. */ /** The show's page, beside a `next` episode. */
onInfo?: () => void; onInfo?: () => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const preview = useMediaPreview(); const preview = useMediaPreview();
// An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster // 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 // 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 acted = ep ?? card;
const watched = card.status === "watched"; const watched = card.status === "watched";
const actedWatched = acted.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 pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
const m = more(card, menuButtonProps, menuOpen); const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined; const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
@@ -317,7 +318,7 @@ export function PlexPosterCard({
const rating = card.rating != null && ( const rating = card.rating != null && (
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<Star className="w-3 h-3 fill-current text-rating" aria-hidden /> <Star className="w-3 h-3 fill-current text-rating" aria-hidden />
{card.rating.toFixed(1)} {formatDecimal(card.rating, lang)}
</span> </span>
); );
return ( return (
@@ -649,11 +650,12 @@ export function EpisodeCard({
menuOpen?: boolean; menuOpen?: boolean;
navIndex?: number; navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const watched = ep.status === "watched"; const watched = ep.status === "watched";
const pct = watched ? 0 : progressPct(ep); const pct = watched ? 0 : progressPct(ep);
const m = more(ep, menuButtonProps, menuOpen); const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds); const runtime = formatRuntime(ep.duration_seconds, lang);
return ( return (
<MediaCard <MediaCard
dense dense
+11 -5
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { Slider as Range, CheckRow } from "./ui/NativeControls"; import { Slider as Range, CheckRow } from "./ui/NativeControls";
import { formatDecimal, formatUnit } from "../lib/format";
// --- settings-panel building blocks ------------------------------------------------------------ // --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) { export function PanelHead({ children }: { children: ReactNode }) {
@@ -17,6 +18,7 @@ export function Slider({
max, max,
step, step,
suffix, suffix,
seconds,
onChange, onChange,
onReset, onReset,
}: { }: {
@@ -25,20 +27,24 @@ export function Slider({
min: number; min: number;
max: number; max: number;
step: number; step: number;
/** A symbol after the number ("%", "px"). */
suffix?: string; 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; onChange: (v: number) => void;
onReset?: () => 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 ( return (
<div className="px-1 py-1"> <div className="px-1 py-1">
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80"> <div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
<span>{label}</span> <span>{label}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="tabular-nums text-white/60"> <span className="tabular-nums text-white/60">{shown}</span>
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix ?? ""}
</span>
{onReset && ( {onReset && (
<IconButton <IconButton
variant="overlay" variant="overlay"
@@ -21,7 +21,10 @@ beforeAll(() => {
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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) => ({ 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 { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings"; import { PageHeading } from "./ui/Headings";
import { vocab } from "../lib/vocab"; 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 // 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, // 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; onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void; onOpenPerson: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = plexLang(); const lang = plexLang(i18n.language);
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return ( return (
<div className="w-[90%] max-w-400 mx-auto"> <div className="w-[90%] max-w-400 mx-auto">
@@ -137,13 +138,13 @@ export function PlexPersonView({
onInfoCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void; onFilter: (patch: Partial<PlexFilters>) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three // 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 // 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). // mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>(); const menu = useContextMenu<PlexCard>();
const lang = plexLang(); const lang = plexLang(i18n.language);
const personKey = qk.plexPerson(name, lang); const personKey = qk.plexPerson(name, lang);
const q = useQuery({ queryKey: personKey, queryFn: () => api.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 // 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) && ( {(d.birthday || d.place_of_birth) && (
<p> <p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "} <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> </p>
)} )}
{d.deathday && ( {d.deathday && (
<p> <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> </p>
)} )}
{d.also_known_as.length > 0 && ( {d.also_known_as.length > 0 && (
@@ -345,10 +347,10 @@ export function PlexShowView({
onFilter?: (patch: Partial<PlexFilters>) => void; onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void; onOpenPerson?: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const lang = plexLang(); const lang = plexLang(i18n.language);
const q = useQuery({ const q = useQuery({
queryKey: qk.plexShow(showId, lang), queryKey: qk.plexShow(showId, lang),
queryFn: () => api.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 */} {/* 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" /> <Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)} {formatDecimal(score, lang)}
</Pressable> </Pressable>
); );
})()} })()}
@@ -671,9 +673,9 @@ export function PlexSeasonView({
onBack: () => void; onBack: () => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const lang = plexLang(); const lang = plexLang(i18n.language);
const q = useQuery({ const q = useQuery({
queryKey: qk.plexShow(showId, lang), queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang),
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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); afterEach(cleanup);
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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(() => { 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. // the hidden-filter cue leaves those out, it would name them twice.
surfacedFilters?: string[]; surfacedFilters?: string[];
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise // 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. // the table keeps its own, seeded from and saved to persistKey.
@@ -212,7 +213,7 @@ export default function DataTable<T>({
setPage(0); setPage(0);
}, [resetFiltersToken, commitFilters]); }, [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. // size <= 0 means "All" — one page with every row.
const allRows = size <= 0; const allRows = size <= 0;
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string; count?: number }) => t: (key: string, o?: { name?: string; count?: number }) =>
o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key, o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key,
}), }),
@@ -30,7 +30,7 @@ describe("ItemCount", () => {
render(<ItemCount count={192045} />); render(<ItemCount count={192045} />);
// Separators come from the shared formatNumber — compare against it, not a fixed en-US string, // 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. // 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 // 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", () => { it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render(<ItemCount count={1_000_000} />); render(<ItemCount count={1_000_000} />);
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(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", () => { 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) // "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. // so any surface can reuse it for whatever it counts.
export default function ItemCount({ count }: { count?: number }) { 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 // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
// show the icon until the number arrives. // show the icon until the number arrives.
const label = const label =
@@ -22,12 +23,12 @@ export default function ItemCount({ count }: { count?: number }) {
? "" ? ""
: t("common.itemCount", { : t("common.itemCount", {
count, count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count), formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang),
}); });
return ( return (
<span <span
data-testid="item-count" 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" 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" /> <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 { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format"; 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"; import { RelativeTime } from "./RelativeTime";
afterEach(cleanup); afterEach(cleanup);
@@ -14,8 +15,8 @@ describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => { it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />); const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!; const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO)); expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO)); expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO); expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
}); });
@@ -23,7 +24,7 @@ describe("RelativeTime", () => {
const ms = Date.parse(THREE_DAYS_AGO); const ms = Date.parse(THREE_DAYS_AGO);
const { container } = render(<RelativeTime at={ms} seconds />); const { container } = render(<RelativeTime at={ms} seconds />);
expect(container.querySelector("time")!.getAttribute("title")).toBe( 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"; 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 // 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; empty?: string;
className?: string; className?: string;
}) { }) {
const lang = useTranslation().i18n.language;
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN; 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; if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString(); const iso = new Date(ms).toISOString();
return ( return (
<time <time
dateTime={iso} dateTime={iso}
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)} title={seconds ? formatDateTimeSec(iso, lang) : formatDateTime(iso, lang)}
className={className} className={className}
> >
{text} {text}
@@ -18,7 +18,10 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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) => ({ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()), ...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache"; import { invalidateSubscription } from "../lib/subscriptionCache";
import { api, type ManagedChannel, type Tag } from "../lib/api"; import { api, type ManagedChannel, type Tag } from "../lib/api";
import { priorityChoices } from "../lib/channelOrder"; import { priorityChoices } from "../lib/channelOrder";
import { compareText } from "../lib/collate";
import { channelYouTubeUrl } from "../lib/format"; import { channelYouTubeUrl } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -40,7 +41,8 @@ export function useChannelActions({
onPickPriority: (c: ManagedChannel, priority: number) => void; onPickPriority: (c: ManagedChannel, priority: number) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const confirm = useConfirm(); const confirm = useConfirm();
const me = useMe(); const me = useMe();
const isAdmin = me.role === "admin"; const isAdmin = me.role === "admin";
@@ -166,7 +168,7 @@ export function useChannelActions({
return { return {
...base, ...base,
items: [...tags] items: [...tags]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => compareText(a.name, b.name, lang))
.map((tg) => ({ .map((tg) => ({
id: `tag-${tg.id}`, id: `tag-${tg.id}`,
label: tg.name, 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 * 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. */ * driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
export function usePlexPlaylistEdits(playlistId: number) { export function usePlexPlaylistEdits(playlistId: number) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE // 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). // 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 key = qk.plexPlaylist(playlistId, lang);
const q = useQuery({ const q = useQuery({
queryKey: key, queryKey: key,
+7 -5
View File
@@ -67,11 +67,13 @@ export function setLanguage(code: LangCode): void {
document.documentElement.lang = code; document.documentElement.lang = code;
} }
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of // The Plex overlay serves only EN or HU metadata (E5 S6). Collapse a UI language to one of those for
// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which // the `?lang=` param / query keys. It takes the language instead of reading the singleton (E30 S3):
// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks). // under the React Compiler a component's `plexLang()` was a value with no reactive input, so it kept
export function plexLang(): "en" | "hu" { // the language the component first rendered in — a switch left every Plex query on the old language.
return i18n.language?.startsWith("hu") ? "hu" : "en"; // 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; export default i18n;
+3 -5
View File
@@ -4,7 +4,7 @@
// so the runtime graph is a clean barrel → domain → core with no cycles. // so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications"; import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { formatBytes } from "../format"; import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount"; import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
@@ -86,9 +86,7 @@ function localizeDetail(d: unknown): string {
| undefined; | undefined;
if (o?.code === "quota") { if (o?.code === "quota") {
if (o.reason === "max_bytes") { if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
(o.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb }); return t("downloads.errors.quota.max_bytes", { size: gb });
} }
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit }); 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"); : t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code. // 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") 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"); 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 // 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, // 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", () => { describe("sortRows", () => {
it("returns the same array reference when there is no sort", () => { 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", () => { 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", () => { it("sorts numbers by numeric difference, not string order", () => {
// string order would put "100" before "30"; numeric must not. // 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]); 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]); expect(desc).toEqual([100, 30, 5]);
}); });
it("sorts strings with locale compare, ascending and descending", () => { 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", "alpha",
"beta", "beta",
"gamma", "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", "gamma",
"beta", "beta",
"alpha", "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", () => { it("does not mutate the input array", () => {
const before = rows.map((r) => r.name); 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); 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. // 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. // 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; export type SortState = { key: string; dir: "asc" | "desc" } | null;
interface Sortable<T> { interface Sortable<T> {
@@ -9,9 +11,10 @@ interface Sortable<T> {
sortValue?: (row: T) => string | number; sortValue?: (row: T) => string | number;
} }
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare). /** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */ * language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] { * `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
if (!sort) return rows; if (!sort) return rows;
const get = columns.find((c) => c.key === sort.key)?.sortValue; const get = columns.find((c) => c.key === sort.key)?.sortValue;
if (!get) return rows; if (!get) return rows;
@@ -21,7 +24,7 @@ export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState):
const cmp = const cmp =
typeof av === "number" && typeof bv === "number" typeof av === "number" && typeof bv === "number"
? av - bv ? av - bv
: String(av).localeCompare(String(bv)); : compareText(String(av), String(bv), lang);
return sort.dir === "asc" ? cmp : -cmp; return sort.dir === "asc" ? cmp : -cmp;
}); });
} }
+8 -2
View File
@@ -3,6 +3,7 @@
// chips can label a sort without re-deriving the mapping. // chips can label a sort without re-deriving the mapping.
import type { Video } from "./api"; import type { Video } from "./api";
import type { SortOption } from "./columnSort"; import type { SortOption } from "./columnSort";
import { compareText } from "./collate";
export type SortKey = export type SortKey =
| "date" | "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 * 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 — * 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. */ * 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]; const value = YT_SORT_VALUE[key];
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
const sign = dir === "asc" ? 1 : -1; const sign = dir === "asc" ? 1 : -1;
@@ -109,7 +115,7 @@ export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc")
have.sort((a, b) => { have.sort((a, b) => {
const x = value(a)!; const x = value(a)!;
const y = value(b)!; 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]; return [...have, ...missing];
} }
+34 -14
View File
@@ -46,7 +46,7 @@ describe("YT_SORT_KEYS", () => {
describe("sortYtResults", () => { describe("sortYtResults", () => {
it("leaves the provider's ranking alone on `relevance`", () => { 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", "b-old-short",
"a-new-long", "a-new-long",
"c-mid-mid", "c-mid-mid",
@@ -54,12 +54,12 @@ describe("sortYtResults", () => {
}); });
it("orders by upload date in both directions", () => { 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", "a-new-long",
"c-mid-mid", "c-mid-mid",
"b-old-short", "b-old-short",
]); ]);
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([ expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([
"b-old-short", "b-old-short",
"c-mid-mid", "c-mid-mid",
"a-new-long", "a-new-long",
@@ -67,14 +67,18 @@ describe("sortYtResults", () => {
}); });
it("orders by views and by length", () => { it("orders by views and by length", () => {
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid"); expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long"); expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short"); expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short");
}); });
it("orders by title alphabetically, not by code point", () => { it("orders by title alphabetically, not by code point", () => {
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })]; 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", () => { 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: "long", duration_seconds: 900 }),
v({ title: "short", duration_seconds: 60 }), v({ title: "short", duration_seconds: 60 }),
]; ];
expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]); expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([
expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]); "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", () => { 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: "second", view_count: 100 }),
v({ title: "third", view_count: 5 }), v({ title: "third", view_count: 5 }),
]; ];
expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]); expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([
expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]); "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", () => { 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`. // 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, "priority", "desc", "en"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS)); expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS));
}); });
it("does not mutate the array it was given", () => { it("does not mutate the array it was given", () => {
const input = [...RESULTS]; const input = [...RESULTS];
sortYtResults(input, "date", "asc"); sortYtResults(input, "date", "asc", "en");
expect(titles(input)).toEqual(titles(RESULTS)); expect(titles(input)).toEqual(titles(RESULTS));
}); });
}); });
+71 -28
View File
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
RELATIVE_UNITS, RELATIVE_UNITS,
formatBytes,
formatCompact,
formatCountOrDash,
formatDecimal,
formatMoney,
formatDate, formatDate,
formatDateTime, formatDateTime,
formatDateTimeSec, formatDateTimeSec,
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
for (const [secs, tier, expectedEn, expectedHu] of CASES) { for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { 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"; 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", () => { it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000; const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
i18nStub.language = "hu"; i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája"); expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
i18nStub.language = "en"; 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", () => { 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", () => { it("renders nothing for a timestamp it cannot read", () => {
expect(relativeTime("not-a-date")).toBe(""); expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
expect(relativeTime(null)).toBe(""); expect(relativeTime(null, i18nStub.language)).toBe("");
expect(relativeFromMs(NaN)).toBe(""); expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
}); });
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering // 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", () => { it("groups in the UI language, not in the runtime's", () => {
const asEnglish = formatNumber(1234567); const asEnglish = formatNumber(1234567, i18nStub.language);
i18nStub.language = "hu"; i18nStub.language = "hu";
const asHungarian = formatNumber(1234567); const asHungarian = formatNumber(1234567, i18nStub.language);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567)); expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
expect(asHungarian).toBe(new Intl.NumberFormat("hu").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. // 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)", () => { 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"; 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"; 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", () => { it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
expect(formatNumber(null)).toBe(""); expect(formatNumber(null, i18nStub.language)).toBe("");
expect(formatNumber(undefined)).toBe(""); expect(formatNumber(undefined, i18nStub.language)).toBe("");
expect(formatNumber(NaN)).toBe(""); expect(formatNumber(NaN, i18nStub.language)).toBe("");
expect(formatNumber(Infinity)).toBe(""); expect(formatNumber(Infinity, i18nStub.language)).toBe("");
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one 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", () => { it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu"; 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) => { 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", () => { it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
i18nStub.language = "hu"; i18nStub.language = "hu";
expect(formatRuntime(5025)).toBe("1 ó 23 p"); expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
i18nStub.language = "en"; i18nStub.language = "en";
}); });
it("never reports a non-zero duration as zero", () => { it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) { 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", () => { it("formats a timestamp in the UI language, not in the runtime's", () => {
const iso = "2026-09-17T08:30:14Z"; const iso = "2026-09-17T08:30:14Z";
const asEnglish = formatDateTime(iso); const asEnglish = formatDateTime(iso, i18nStub.language);
i18nStub.language = "hu"; i18nStub.language = "hu";
const asHungarian = formatDateTime(iso); const asHungarian = formatDateTime(iso, i18nStub.language);
expect(asEnglish).not.toBe(asHungarian); expect(asEnglish).not.toBe(asHungarian);
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu")); 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)", () => { it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
process.env.TZ = "UTC"; process.env.TZ = "UTC";
const iso = "2026-09-17T08:30:14Z"; const iso = "2026-09-17T08:30:14Z";
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM"); expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM"); expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
i18nStub.language = "hu"; 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."); 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"); 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"; import i18n from "../i18n";
export function relativeTime(iso: string | null): string { // The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
if (!iso) return ""; // all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
return relativeFromMs(new Date(iso).getTime()); //
// 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 relativeTime(iso: string | null, lang: string): string {
export function formatBytes(bytes: number | null | undefined): 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); const n = Number(bytes);
if (!n || n < 0) return "0 B"; if (!n || n < 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"]; const units = ["B", "KB", "MB", "GB", "TB"];
@@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string {
v /= 1024; v /= 1024;
i++; 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". */ /** 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 ""; 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, /** 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 * 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 * 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. */ * 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); const secs = Math.max(0, (Date.now() - ms) / 1000);
if (!Number.isFinite(secs)) return ""; if (!Number.isFinite(secs)) return "";
// Sub-minute is the one case Intl has no wording for — it would say "0m ago". // 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]; let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitName] of RELATIVE_UNITS) { for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break; if (secs < unitSecs) break;
div = unitSecs; div = unitSecs;
unit = unitName; 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 // 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 // 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). // came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
export type DurationUnit = "second" | "minute" | "hour" | "day"; export type DurationUnit = "second" | "minute" | "hour" | "day";
const unitFormatters = new Map<string, Intl.NumberFormat>(); export function formatUnit(
export function formatUnit(value: number, unit: DurationUnit): string { value: number,
const key = `${i18n.language}|${unit}`; unit: DurationUnit,
let f = unitFormatters.get(key); lang: string,
if (!f) { digits?: number,
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" }); ): string {
unitFormatters.set(key, f); const fraction =
} digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits };
return f.format(value); 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 /** 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 * 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 * 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. */ * 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) 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 h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60); 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 // 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 // from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
// key for any unmapped/legacy value. // key for any unmapped/legacy value.
export function quotaActionLabel(action: string): string { export function quotaActionLabel(t: TFunction, action: string): string {
return i18n.t(`quotaActions.${action}`, { defaultValue: action }); return t(`quotaActions.${action}`, { defaultValue: action });
} }
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated /** 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). */ * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
export function formatEta(seconds: number): string { export function formatEta(t: TFunction, seconds: number): string {
if (seconds <= 0) return i18n.t("time.eta.done"); if (seconds <= 0) return t("time.eta.done");
const hours = seconds / 3600; const hours = seconds / 3600;
if (hours < 1) return i18n.t("time.eta.lessThanHour"); if (hours < 1) return t("time.eta.lessThanHour");
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) });
return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); 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. */ /** 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 "—"; if (!sec) return "—";
const h = Math.round(sec / 3600); 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 /** 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 * `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 * 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 * `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
* `|| "—"` fallback covers it like a missing one. */ * `|| "—"` 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 ""; if (n == null || !Number.isFinite(n)) return "";
const lang = i18n.language; return numberFormat(lang).format(n);
let formatter = numberFormatters.get(lang);
if (!formatter) {
formatter = new Intl.NumberFormat(lang);
numberFormatters.set(lang, formatter);
}
return formatter.format(n);
} }
export function formatViews(n: number | null): string { /** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed`
if (n == null) return ""; * always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string {
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits };
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; return numberFormat(lang, opts).format(n);
return String(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). // 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 { export function formatCountOrDash(n: number | null | undefined, lang: string): string {
return n != null ? formatViews(n) : "—"; 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 // 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 DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" }; const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
export function formatDateTime(iso: string | null | undefined): string { export function formatDateTime(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : ""; return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
} }
export function formatDateTimeSec(iso: string | null | undefined): string { export function formatDateTimeSec(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : ""; 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. // 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", () => { it("names each count, and a phase's own counts inside it", () => {
setLanguage("hu"); setLanguage("hu");
const t = i18n.getFixedT("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( expect(
jobResultText(t, { jobResultText(
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" }, t,
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 }, {
}), recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
},
"hu",
),
).toBe( ).toBe(
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " + "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)", "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", () => { it("names a skip reason; an upstream error stays its own text", () => {
const t = i18n.getFixedT("hu"); 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", "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", () => { it("shows a key or word it does not know as it is, readable", () => {
const t = i18n.getFixedT("hu"); 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", "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 * 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 * 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). */ * 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) return Object.entries(result)
.map(([key, value]) => { .map(([key, value]) => {
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") }); 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") if (typeof value === "string")
return key === "error" return key === "error"
? `${label}: ${value}` ? `${label}: ${value}`
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`; : `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
return `${label} (${jobResultText(t, value)})`; return `${label} (${jobResultText(t, value, lang)})`;
}) })
.join(" · "); .join(" · ");
} }
+45 -8
View File
@@ -2,18 +2,19 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit"; import { eslint } from "./lintProbe.testkit";
// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms // `local/locale-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date, a number and an
// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to // alphabetical order are in the UI language, built in lib/format.ts (text compared in lib/collate.ts),
// be formatted by hand, and where it stands down. // and a component takes the language off useTranslation, never the i18n singleton. This pins that the
const RULE = "local/date-format"; // 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> { async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const [result] = await eslint.lintText(source, { filePath }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
} }
describe("a hand-formatted date", () => { describe("a hand-formatted date or number", () => {
it("is refused as toLocale*String or an Intl date formatter", async () => { 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 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 b = (d: Date) => d.toLocaleDateString();\n")).toBe(1);
expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\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"), await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"),
).toBe(1); ).toBe(1);
expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\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"; 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.ts")).toBe(0);
expect(await hits(src, "src/lib/format.test.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", () => { 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", () => { 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", "alpha",
"bravo", "bravo",
"charlie", "charlie",
"delta", "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", "delta",
"charlie", "charlie",
"bravo", "bravo",
@@ -65,7 +65,7 @@ describe("applyView", () => {
}); });
it("groups by channel, ordering the groups by name and sorting within each", () => { 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 "alpha", // Alpha
"bravo", // Alpha "bravo", // Alpha
"charlie", // Beta "charlie", // Beta
@@ -74,7 +74,7 @@ describe("applyView", () => {
}); });
it("reverses the GROUP order too when the sort is descending", () => { 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 "delta", // Beta first, because the direction flips the group order as well
"charlie", "charlie",
"bravo", // then Alpha "bravo", // then Alpha
@@ -83,7 +83,7 @@ describe("applyView", () => {
}); });
it("groups without a sort by keeping each group's stored order", () => { 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", "alpha",
"bravo", "bravo",
"delta", "delta",
@@ -93,11 +93,9 @@ describe("applyView", () => {
it("parks unknown durations at the end of an ascending sort, not at zero", () => { 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)]; 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([ expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false, "en"))).toEqual(
"short", ["short", "long", "live"],
"long", );
"live",
]);
}); });
}); });
+4 -2
View File
@@ -1,4 +1,5 @@
import { sortRows, type SortState } from "./columnSort"; 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 // 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 // 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>[], columns: Sortable<T>[],
sort: SortState, sort: SortState,
group: boolean, group: boolean,
lang: string,
): T[] { ): T[] {
const sorted = sortRows(rows, columns, sort); const sorted = sortRows(rows, columns, sort, lang);
if (!group) return sorted; if (!group) return sorted;
const groups = new Map<string, T[]>(); const groups = new Map<string, T[]>();
for (const v of sorted) { for (const v of sorted) {
@@ -32,7 +34,7 @@ export function applyView<T extends Grouped>(
else groups.set(k, [v]); else groups.set(k, [v]);
} }
const sign = sort?.dir === "desc" ? -1 : 1; 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)!); return keys.flatMap((k) => groups.get(k)!);
} }
+3 -3
View File
@@ -17,7 +17,7 @@ import {
} from "./fullscreen"; } from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api"; import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { plexLang } from "../i18n"; import i18n, { plexLang } from "../i18n";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs"; import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume"; import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -264,7 +264,7 @@ export function usePlexProgress(opts: {
// checkpoint (the resume point when leaving). // checkpoint (the resume point when leaving).
const settle = () => { const settle = () => {
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands. // 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) { if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the // 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 // 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 // `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 // 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. // 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 () => { return () => {

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