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
+3 -5
View File
@@ -4,7 +4,7 @@
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
@@ -86,9 +86,7 @@ function localizeDetail(d: unknown): string {
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(o.limit || 0) / 1_073_741_824,
);
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
@@ -117,7 +115,7 @@ function localizeDetail(d: unknown): string {
: t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
+13
View File
@@ -0,0 +1,13 @@
// Alphabetical order in the UI language (E30 S3): Hungarian puts "Ö" after "O" and "Cs" after "Cu". A
// bare `localeCompare` compares in the BROWSER's locale; the server sorts by the same ICU rules
// (backend `app/collation.py`), so a list sorted here agrees with one the server sorted. Its own
// module, apart from lib/format.ts, because it needs no i18n: the pure sort helpers import it.
const collators = new Map<string, Intl.Collator>();
export function compareText(a: string, b: string, lang: string): number {
let c = collators.get(lang);
if (!c) {
c = new Intl.Collator(lang);
collators.set(lang, c);
}
return c.compare(a, b);
}
+21 -7
View File
@@ -27,37 +27,51 @@ const columns = [
describe("sortRows", () => {
it("returns the same array reference when there is no sort", () => {
expect(sortRows(rows, columns, null)).toBe(rows);
expect(sortRows(rows, columns, null, "en")).toBe(rows);
});
it("returns the input unchanged for a column that has no sortValue", () => {
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows);
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" }, "en")).toBe(rows);
});
it("sorts numbers by numeric difference, not string order", () => {
// string order would put "100" before "30"; numeric must not.
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views);
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }, "en").map((r) => r.views);
expect(asc).toEqual([5, 30, 100]);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }, "en").map((r) => r.views);
expect(desc).toEqual([100, 30, 5]);
});
it("sorts strings with locale compare, ascending and descending", () => {
expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([
expect(sortRows(rows, columns, { key: "name", dir: "asc" }, "en").map((r) => r.name)).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([
expect(sortRows(rows, columns, { key: "name", dir: "desc" }, "en").map((r) => r.name)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
// E30 S3: text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after
// "O" and "Cs" after "Cu". Both languages are asserted and differ, so a bare `localeCompare` (one
// runtime locale for both) cannot pass on any machine.
it("sorts text in the UI language's alphabet", () => {
const words = ["Zorró", "Ötödik", "oroszlán", "Csillag", "Életrajzi", "Cukor"].map((name) => ({
name,
views: 0,
}));
const by = (lang: string) =>
sortRows(words, columns, { key: "name", dir: "asc" }, lang).map((r) => r.name);
expect(by("hu")).toEqual(["Cukor", "Csillag", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
expect(by("en")).toEqual(["Csillag", "Cukor", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
});
it("does not mutate the input array", () => {
const before = rows.map((r) => r.name);
sortRows(rows, columns, { key: "name", dir: "asc" });
sortRows(rows, columns, { key: "name", dir: "asc" }, "en");
expect(rows.map((r) => r.name)).toEqual(before);
});
});
+7 -4
View File
@@ -2,6 +2,8 @@
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
import { compareText } from "./collate";
export type SortState = { key: string; dir: "asc" | "desc" } | null;
interface Sortable<T> {
@@ -9,9 +11,10 @@ interface Sortable<T> {
sortValue?: (row: T) => string | number;
}
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
* language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
* `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
if (!sort) return rows;
const get = columns.find((c) => c.key === sort.key)?.sortValue;
if (!get) return rows;
@@ -21,7 +24,7 @@ export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState):
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
: compareText(String(av), String(bv), lang);
return sort.dir === "asc" ? cmp : -cmp;
});
}
+8 -2
View File
@@ -3,6 +3,7 @@
// chips can label a sort without re-deriving the mapping.
import type { Video } from "./api";
import type { SortOption } from "./columnSort";
import { compareText } from "./collate";
export type SortKey =
| "date"
@@ -100,7 +101,12 @@ const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | nul
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
export function sortYtResults(
items: Video[],
key: SortKey,
dir: "asc" | "desc",
lang: string,
): Video[] {
const value = YT_SORT_VALUE[key];
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
const sign = dir === "asc" ? 1 : -1;
@@ -109,7 +115,7 @@ export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc")
have.sort((a, b) => {
const x = value(a)!;
const y = value(b)!;
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y));
});
return [...have, ...missing];
}
+34 -14
View File
@@ -46,7 +46,7 @@ describe("YT_SORT_KEYS", () => {
describe("sortYtResults", () => {
it("leaves the provider's ranking alone on `relevance`", () => {
expect(titles(sortYtResults(RESULTS, "relevance", "desc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "relevance", "desc", "en"))).toEqual([
"b-old-short",
"a-new-long",
"c-mid-mid",
@@ -54,12 +54,12 @@ describe("sortYtResults", () => {
});
it("orders by upload date in both directions", () => {
expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "date", "desc", "en"))).toEqual([
"a-new-long",
"c-mid-mid",
"b-old-short",
]);
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([
expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([
"b-old-short",
"c-mid-mid",
"a-new-long",
@@ -67,14 +67,18 @@ describe("sortYtResults", () => {
});
it("orders by views and by length", () => {
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short");
expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short");
});
it("orders by title alphabetically, not by code point", () => {
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })];
expect(titles(sortYtResults(accented, "title", "asc"))).toEqual(["Ábel", "alma", "Zebra"]);
expect(titles(sortYtResults(accented, "title", "asc", "en"))).toEqual([
"Ábel",
"alma",
"Zebra",
]);
});
it("sinks results with no value to the END in both directions", () => {
@@ -84,8 +88,16 @@ describe("sortYtResults", () => {
v({ title: "long", duration_seconds: 900 }),
v({ title: "short", duration_seconds: 60 }),
];
expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]);
expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]);
expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([
"long",
"short",
"unknown",
]);
expect(titles(sortYtResults(mixed, "duration", "asc", "en"))).toEqual([
"short",
"long",
"unknown",
]);
});
it("keeps the provider's ranking inside a tie, in both directions", () => {
@@ -95,19 +107,27 @@ describe("sortYtResults", () => {
v({ title: "second", view_count: 100 }),
v({ title: "third", view_count: 5 }),
];
expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]);
expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]);
expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([
"first",
"second",
"third",
]);
expect(titles(sortYtResults(tied, "popular", "asc", "en"))).toEqual([
"third",
"first",
"second",
]);
});
it("does not reorder anything for a key it cannot honour", () => {
// A pref stored before this view offered a control can name `shuffle` or `priority`.
expect(titles(sortYtResults(RESULTS, "priority", "desc"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "priority", "desc", "en"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS));
});
it("does not mutate the array it was given", () => {
const input = [...RESULTS];
sortYtResults(input, "date", "asc");
sortYtResults(input, "date", "asc", "en");
expect(titles(input)).toEqual(titles(RESULTS));
});
});
+71 -28
View File
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RELATIVE_UNITS,
formatBytes,
formatCompact,
formatCountOrDash,
formatDecimal,
formatMoney,
formatDate,
formatDateTime,
formatDateTimeSec,
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn);
i18nStub.language = "hu";
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu);
});
}
it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
i18nStub.language = "en";
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
});
it("clamps a future timestamp instead of counting up", () => {
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow");
});
it("renders nothing for a timestamp it cannot read", () => {
expect(relativeTime("not-a-date")).toBe("");
expect(relativeTime(null)).toBe("");
expect(relativeFromMs(NaN)).toBe("");
expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
expect(relativeTime(null, i18nStub.language)).toBe("");
expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
});
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
@@ -135,9 +140,9 @@ describe("formatNumber", () => {
});
it("groups in the UI language, not in the runtime's", () => {
const asEnglish = formatNumber(1234567);
const asEnglish = formatNumber(1234567, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatNumber(1234567);
const asHungarian = formatNumber(1234567, i18nStub.language);
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
@@ -145,24 +150,26 @@ describe("formatNumber", () => {
});
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
i18nStub.language = "hu";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
i18nStub.language = "en";
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
});
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
expect(formatNumber(null)).toBe("");
expect(formatNumber(undefined)).toBe("");
expect(formatNumber(NaN)).toBe("");
expect(formatNumber(Infinity)).toBe("");
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
expect(formatNumber(null, i18nStub.language)).toBe("");
expect(formatNumber(undefined, i18nStub.language)).toBe("");
expect(formatNumber(NaN, i18nStub.language)).toBe("");
expect(formatNumber(Infinity, i18nStub.language)).toBe("");
expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one
});
it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu";
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe(
`${new Intl.NumberFormat("hu").format(12345)} ó`,
);
});
});
@@ -185,18 +192,18 @@ describe("formatRuntime", () => {
];
it.each(CASES)("formats %o as %o", (sec, expected) => {
expect(formatRuntime(sec)).toBe(expected);
expect(formatRuntime(sec, i18nStub.language)).toBe(expected);
});
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
i18nStub.language = "hu";
expect(formatRuntime(5025)).toBe("1 ó 23 p");
expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
i18nStub.language = "en";
});
it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) {
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min");
}
});
});
@@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => {
it("formats a timestamp in the UI language, not in the runtime's", () => {
const iso = "2026-09-17T08:30:14Z";
const asEnglish = formatDateTime(iso);
const asEnglish = formatDateTime(iso, i18nStub.language);
i18nStub.language = "hu";
const asHungarian = formatDateTime(iso);
const asHungarian = formatDateTime(iso, i18nStub.language);
expect(asEnglish).not.toBe(asHungarian);
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
});
@@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => {
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
process.env.TZ = "UTC";
const iso = "2026-09-17T08:30:14Z";
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
i18nStub.language = "hu";
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30");
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
});
@@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => {
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
});
});
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
// asserted and differ, so an implementation that ignores the language cannot pass.
describe("numbers in the UI language", () => {
it("writes a rating with the language's decimal mark", () => {
expect(formatDecimal(8.25, "en")).toBe("8.3");
expect(formatDecimal(8.25, "hu")).toBe("8,3");
expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal
expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one
});
it("writes a big count compact, with the language's suffix", () => {
// Intl keeps the number and its suffix together with a no-break space
const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " ");
expect(formatCompact(1234, "en")).toBe("1.2K");
expect(hu(1234)).toBe("1,2 E");
expect(formatCompact(3_400_000, "en")).toBe("3.4M");
expect(hu(3_400_000)).toBe("3,4 M");
expect(formatCompact(999, "hu")).toBe("999");
expect(formatCompact(null, "hu")).toBe("");
expect(formatCountOrDash(null, "hu")).toBe("—");
});
it("writes a file size with the language's decimal mark", () => {
expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB");
expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB");
expect(formatBytes(501747, "hu")).toBe("490 KB");
expect(formatBytes(0, "hu")).toBe("0 B");
});
it("writes a dollar sum compact, never padded", () => {
expect(formatMoney(160_000_000, "en")).toBe("$160M");
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
});
});
+98 -57
View File
@@ -1,12 +1,36 @@
import type { TFunction } from "i18next";
import i18n from "../i18n";
export function relativeTime(iso: string | null): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime());
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
//
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style
// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
// dependency the compiler can see; the signature makes forgetting it a type error.
// Intl formatters are expensive to build and every grid formats one per card: one per language ×
// options, built lazily. Keyed by language because the user can switch it at runtime.
const numberFormats = new Map<string, Intl.NumberFormat>();
function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat {
const key = `${lang}|${JSON.stringify(opts)}`;
let f = numberFormats.get(key);
if (!f) {
f = new Intl.NumberFormat(lang, opts);
numberFormats.set(key, f);
}
return f;
}
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
export function formatBytes(bytes: number | null | undefined): string {
export function relativeTime(iso: string | null, lang: string): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), lang);
}
/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB",
* 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */
export function formatBytes(bytes: number | null | undefined, lang: string): string {
const n = Number(bytes);
if (!n || n < 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string {
v /= 1024;
i++;
}
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang);
return `${num} ${units[i]}`;
}
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
export function formatSpeed(bps: number | null | undefined): string {
export function formatSpeed(bps: number | null | undefined, lang: string): string {
if (!bps) return "";
return `${formatBytes(bps)}/s`;
return `${formatBytes(bps, lang)}/s`;
}
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
@@ -65,18 +90,18 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
* client-side notifications whose timestamps are already numbers). One implementation shared
* instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
* caller's `|| "—"` fallback covers it like a missing one. */
export function relativeFromMs(ms: number): string {
export function relativeFromMs(ms: number, lang: string): string {
const secs = Math.max(0, (Date.now() - ms) / 1000);
if (!Number.isFinite(secs)) return "";
// Sub-minute is the one case Intl has no wording for — it would say "0m ago".
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow");
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang });
let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break;
div = unitSecs;
unit = unitName;
}
return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit);
return relativeFormatter(lang).format(-Math.floor(secs / div), unit);
}
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
@@ -100,15 +125,17 @@ export function formatDuration(sec: number | null): string {
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
export type DurationUnit = "second" | "minute" | "hour" | "day";
const unitFormatters = new Map<string, Intl.NumberFormat>();
export function formatUnit(value: number, unit: DurationUnit): string {
const key = `${i18n.language}|${unit}`;
let f = unitFormatters.get(key);
if (!f) {
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
unitFormatters.set(key, f);
}
return f.format(value);
export function formatUnit(
value: number,
unit: DurationUnit,
lang: string,
digits?: number,
): string {
const fraction =
digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits };
return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format(
value,
);
}
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
@@ -118,70 +145,84 @@ export function formatUnit(value: number, unit: DurationUnit): string {
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
export function formatRuntime(sec?: number | null): string {
export function formatRuntime(sec: number | null | undefined, lang: string): string {
if (!sec) return "";
if (sec < 60) return formatUnit(Math.floor(sec), "second");
if (sec < 60) return formatUnit(Math.floor(sec), "second", lang);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
return h
? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}`
: formatUnit(m, "minute", lang);
}
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
// key for any unmapped/legacy value.
export function quotaActionLabel(action: string): string {
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
export function quotaActionLabel(t: TFunction, action: string): string {
return t(`quotaActions.${action}`, { defaultValue: action });
}
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
export function formatEta(seconds: number): string {
if (seconds <= 0) return i18n.t("time.eta.done");
export function formatEta(t: TFunction, seconds: number): string {
if (seconds <= 0) return t("time.eta.done");
const hours = seconds / 3600;
if (hours < 1) return i18n.t("time.eta.lessThanHour");
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
if (hours < 1) return t("time.eta.lessThanHour");
if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) });
return t("time.eta.days", { count: Math.round(hours / 24) });
}
/** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */
export function formatTotalHours(sec: number): string {
export function formatTotalHours(sec: number, lang: string): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
return h >= 1
? formatUnit(h, "hour", lang)
: formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang);
}
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
const numberFormatters = new Map<string, Intl.NumberFormat>();
/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
* `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
* browser showed space-grouped figures and vice versa — the number half of the same defect
* `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
* `|| "—"` fallback covers it like a missing one. */
export function formatNumber(n: number | null | undefined): string {
export function formatNumber(n: number | null | undefined, lang: string): string {
if (n == null || !Number.isFinite(n)) return "";
const lang = i18n.language;
let formatter = numberFormatters.get(lang);
if (!formatter) {
formatter = new Intl.NumberFormat(lang);
numberFormatters.set(lang, formatter);
}
return formatter.format(n);
return numberFormat(lang).format(n);
}
export function formatViews(n: number | null): string {
if (n == null) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
return String(n);
/** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed`
* always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */
export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string {
const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits };
return numberFormat(lang, opts).format(n);
}
/** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is
* pinned for the reason `formatMoney` gives. Empty for null. */
export function formatCompact(n: number | null | undefined, lang: string): string {
if (n == null || !Number.isFinite(n)) return "";
const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const;
return numberFormat(lang, opts).format(n);
}
/** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's
* language. The minimum is load-bearing, not a default written out: given only a maximum, a compact
* format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24
* (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */
export function formatMoney(n: number, lang: string): string {
return numberFormat(lang, {
notation: "compact",
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(n);
}
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
export function formatCountOrDash(n: number | null | undefined, lang: string): string {
return n != null ? formatCompact(n, lang) : "—";
}
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
@@ -195,12 +236,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
export function formatDateTime(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
export function formatDateTime(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
}
export function formatDateTimeSec(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
export function formatDateTimeSec(iso: string | null | undefined, lang: string): string {
return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : "";
}
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
+12 -8
View File
@@ -12,12 +12,16 @@ describe("a job's result", () => {
it("names each count, and a phase's own counts inside it", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { new: 3, failed: 0 })).toBe("új videó: 3 · sikertelen: 0");
expect(jobResultText(t, { new: 3, failed: 0 }, "hu")).toBe("új videó: 3 · sikertelen: 0");
expect(
jobResultText(t, {
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
}),
jobResultText(
t,
{
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
},
"hu",
),
).toBe(
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
@@ -26,15 +30,15 @@ describe("a job's result", () => {
it("names a skip reason; an upstream error stays its own text", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" })).toBe(
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
);
expect(jobResultText(t, { error: "Plex returned 500" })).toBe("hiba: Plex returned 500");
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
});
it("shows a key or word it does not know as it is, readable", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" })).toBe(
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" }, "hu")).toBe(
"brand new count: 3 · kihagyva: moon is full",
);
});
+3 -3
View File
@@ -22,16 +22,16 @@ export function parseSummary(summary: string | null | undefined): JobResult | nu
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
export function jobResultText(t: TFunction, result: JobResult): string {
export function jobResultText(t: TFunction, result: JobResult, lang: string): string {
return Object.entries(result)
.map(([key, value]) => {
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") });
if (typeof value === "number") return `${label}: ${formatNumber(value)}`;
if (typeof value === "number") return `${label}: ${formatNumber(value, lang)}`;
if (typeof value === "string")
return key === "error"
? `${label}: ${value}`
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
return `${label} (${jobResultText(t, value)})`;
return `${label} (${jobResultText(t, value, lang)})`;
})
.join(" · ");
}
+45 -8
View File
@@ -2,18 +2,19 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms
// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to
// be formatted by hand, and where it stands down.
const RULE = "local/date-format";
// `local/locale-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date, a number and an
// alphabetical order are in the UI language, built in lib/format.ts (text compared in lib/collate.ts),
// and a component takes the language off useTranslation, never the i18n singleton. This pins that the
// rule fires on the ways each used to be done by hand, and where it stands down.
const RULE = "local/locale-format";
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a hand-formatted date", () => {
it("is refused as toLocale*String or an Intl date formatter", async () => {
describe("a hand-formatted date or number", () => {
it("is refused as toLocale*String or an Intl date or number formatter", async () => {
expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1);
expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1);
expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1);
@@ -21,12 +22,48 @@ describe("a hand-formatted date", () => {
await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"),
).toBe(1);
expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1);
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(1);
});
it("is left alone in lib/format.ts, in a test and for a number format", async () => {
it("is left alone in lib/format.ts and in a test", async () => {
const src = "export const g = (d: Date) => d.toLocaleString('en');\n";
expect(await hits(src, "src/lib/format.ts")).toBe(0);
expect(await hits(src, "src/lib/format.test.ts")).toBe(0);
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(0);
expect(await hits("export const h = new Intl.NumberFormat('en');\n", "src/lib/format.ts")).toBe(
0,
);
});
});
describe("a hand-rolled alphabetical order", () => {
it("is refused as localeCompare, or an Intl.Collator outside lib/collate.ts", async () => {
expect(await hits("export const s = (a: string, b: string) => a.localeCompare(b);\n")).toBe(1);
expect(
await hits(
"export const s = (a: string, b: string) => a.localeCompare(b);\n",
"src/lib/x.ts",
),
).toBe(1);
expect(await hits("export const c = new Intl.Collator('hu');\n")).toBe(1);
expect(await hits("export const c = new Intl.Collator('hu');\n", "src/lib/collate.ts")).toBe(0);
});
});
describe("the language in a component", () => {
it("is refused off the i18n singleton", async () => {
const src = 'import i18n from "../i18n";\nexport const L = () => <b>{i18n.language}</b>;\n';
expect(await hits(src)).toBe(1);
const next =
'import i18next from "i18next";\nexport const L = () => <b>{i18next.language}</b>;\n';
expect(await hits(next)).toBe(1);
});
it("is taken from useTranslation, and the singleton stays usable outside components", async () => {
const hook =
'import { useTranslation } from "react-i18next";\n' +
"export const L = () => {\n const { i18n } = useTranslation();\n return <b>{i18n.language}</b>;\n};\n";
expect(await hits(hook)).toBe(0);
const lib = 'import i18n from "../i18n";\nexport const lang = () => i18n.language;\n';
expect(await hits(lib, "src/lib/__probe.ts")).toBe(0);
});
});
+9 -11
View File
@@ -46,17 +46,17 @@ describe("applyView", () => {
];
it("returns the stored order untouched with no sort and no grouping", () => {
expect(applyView(rows, columns, null, false)).toBe(rows);
expect(applyView(rows, columns, null, false, "en")).toBe(rows);
});
it("sorts by the named column", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false, "en"))).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
]);
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false, "en"))).toEqual([
"delta",
"charlie",
"bravo",
@@ -65,7 +65,7 @@ describe("applyView", () => {
});
it("groups by channel, ordering the groups by name and sorting within each", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true, "en"))).toEqual([
"alpha", // Alpha
"bravo", // Alpha
"charlie", // Beta
@@ -74,7 +74,7 @@ describe("applyView", () => {
});
it("reverses the GROUP order too when the sort is descending", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true, "en"))).toEqual([
"delta", // Beta first, because the direction flips the group order as well
"charlie",
"bravo", // then Alpha
@@ -83,7 +83,7 @@ describe("applyView", () => {
});
it("groups without a sort by keeping each group's stored order", () => {
expect(titles(applyView(rows, columns, null, true))).toEqual([
expect(titles(applyView(rows, columns, null, true, "en"))).toEqual([
"alpha",
"bravo",
"delta",
@@ -93,11 +93,9 @@ describe("applyView", () => {
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
"short",
"long",
"live",
]);
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false, "en"))).toEqual(
["short", "long", "live"],
);
});
});
+4 -2
View File
@@ -1,4 +1,5 @@
import { sortRows, type SortState } from "./columnSort";
import { compareText } from "./collate";
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
@@ -21,8 +22,9 @@ export function applyView<T extends Grouped>(
columns: Sortable<T>[],
sort: SortState,
group: boolean,
lang: string,
): T[] {
const sorted = sortRows(rows, columns, sort);
const sorted = sortRows(rows, columns, sort, lang);
if (!group) return sorted;
const groups = new Map<string, T[]>();
for (const v of sorted) {
@@ -32,7 +34,7 @@ export function applyView<T extends Grouped>(
else groups.set(k, [v]);
}
const sign = sort?.dir === "desc" ? -1 : 1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
const keys = [...groups.keys()].sort((a, b) => sign * compareText(a, b, lang));
return keys.flatMap((k) => groups.get(k)!);
}
+3 -3
View File
@@ -17,7 +17,7 @@ import {
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { plexLang } from "../i18n";
import i18n, { plexLang } from "../i18n";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -264,7 +264,7 @@ export function usePlexProgress(opts: {
// checkpoint (the resume point when leaving).
const settle = () => {
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang());
const key = qk.plexItem(id, plexLang(i18n.language));
if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the
// rollback restored, and the catch below has already dropped this entry, so the next open
@@ -311,7 +311,7 @@ export function usePlexProgress(opts: {
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang()) });
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
});
};
return () => {
+9 -4
View File
@@ -1,4 +1,5 @@
import { healSort, type SortOption, type SortState } from "./columnSort";
import { compareText } from "./collate";
// Plex's sort vocabulary, source-agnostic like the feed's — a `SortOption[]` fed to the shared
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
@@ -25,12 +26,16 @@ function plexSortKeys(scope: string): string[] {
* SortState would otherwise corrupt the shared fallback for every caller. */
export const PLEX_SORT_FALLBACK: SortState = Object.freeze({ key: "title", dir: "asc" as const });
/** Labelled, alpha-ordered options for the SortControl. `label` resolves each key (i18n
* `plex.sort.<key>`). */
export function plexSortOptions(scope: string, label: (key: string) => string): SortOption[] {
/** Labelled, alpha-ordered options for the SortControl, in the UI language's alphabet. `label`
* resolves each key (i18n `plex.sort.<key>`). */
export function plexSortOptions(
scope: string,
label: (key: string) => string,
lang: string,
): SortOption[] {
return plexSortKeys(scope)
.map((key) => ({ key, label: label(key) }))
.sort((a, b) => a.label.localeCompare(b.label));
.sort((a, b) => compareText(a.label, b.label, lang));
}
/** Heal a stored sort against a scope's available keys, collapsing an out-of-scope key to the default.
+22 -14
View File
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
// format.ts boots i18n on import (localStorage); the formatters here take the language as an argument.
vi.mock("../i18n", () => ({ default: { t: (k: string) => k } }));
import {
buildStatRows,
createFragMeter,
@@ -17,25 +20,28 @@ const DASH = "—";
describe("plexStats formatters", () => {
it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => {
expect(fmtBitrate(undefined)).toBe(DASH);
expect(fmtBitrate(0)).toBe(DASH);
expect(fmtBitrate(820_000)).toBe("820 kbps");
expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps");
expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps");
expect(fmtBitrate(undefined, "en")).toBe(DASH);
expect(fmtBitrate(0, "en")).toBe(DASH);
expect(fmtBitrate(820_000, "en")).toBe("820 kbps");
expect(fmtBitrate(4_200_000, "en")).toBe("4.2 Mbps");
expect(fmtBitrate(1_000_000, "en")).toBe("1.0 Mbps");
expect(fmtBitrate(4_200_000, "hu")).toBe("4,2 Mbps"); // E30 S3: the UI language's decimal mark
});
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
expect(fmtBufferSecs(12.34)).toBe("12.3 s");
expect(fmtBufferSecs(0)).toBe("0.0 s");
expect(fmtBufferSecs(-1)).toBe(DASH);
expect(fmtBufferSecs(Infinity)).toBe(DASH);
expect(fmtBufferSecs(12.34, "en")).toBe("12.3 sec");
expect(fmtBufferSecs(0, "en")).toBe("0.0 sec");
expect(fmtBufferSecs(12.34, "hu")).toBe("12,3 mp");
expect(fmtBufferSecs(-1, "en")).toBe(DASH);
expect(fmtBufferSecs(Infinity, "en")).toBe(DASH);
});
it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => {
expect(fmtFrames(0, 0)).toBe(DASH);
expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)");
expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)");
expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)");
expect(fmtFrames(0, 0, "en")).toBe(DASH);
expect(fmtFrames(0, 1000, "en")).toBe("0 / 1,000 (0.00%)");
expect(fmtFrames(5, 1000, "en")).toBe("5 / 1,000 (0.50%)");
expect(fmtFrames(30, 1000, "en")).toBe("30 / 1,000 (3.0%)");
expect(fmtFrames(30, 1000, "hu")).toBe("30 / 1000 (3,0%)");
});
it("fmtRes dashes an undecoded 0×0 frame", () => {
@@ -68,6 +74,7 @@ describe("plexStats formatters", () => {
describe("buildStatRows", () => {
const base: PlexStatsInput = {
lang: "en",
playable: "remux",
transport: "hls",
videoWidth: 1280,
@@ -199,6 +206,7 @@ describe("measured bitrate", () => {
describe("buildStatRows — what is actually playing", () => {
const hls = (over: Partial<PlexStatsInput> = {}): PlexStatsInput => ({
lang: "en",
playable: "remux",
transport: "hls",
videoWidth: 1920,
+21 -11
View File
@@ -2,8 +2,13 @@
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
// The numbers are in the UI language (E30 S3: "4,2 Mbps", "12,3 mp" in Hungarian); the unit symbols of a
// stream (Mbps, kbps) are the same in both.
import { formatDecimal, formatNumber, formatUnit } from "./format";
export interface PlexStatsInput {
lang: string; // the UI language, for the numbers
// The item's codec classification (backend `classify_playable`): the "active mode" a viewer cares
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
playable: string; // "direct" | "remux" | "transcode" | ""
@@ -53,23 +58,24 @@ export interface StatRow {
const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
export function fmtBitrate(bps: number | undefined): string {
export function fmtBitrate(bps: number | undefined, lang: string): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
return `${Math.round(bps / 1000)} kbps`;
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
return `${formatNumber(Math.round(bps / 1000), lang)} kbps`;
}
// Seconds of buffer ahead of the playhead → "12.3 s".
export function fmtBufferSecs(s: number): string {
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return `${s.toFixed(1)} s`;
return formatUnit(s, "second", lang, 1);
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
export function fmtFrames(dropped: number, total: number): string {
export function fmtFrames(dropped: number, total: number, lang: string): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
return `${dropped} / ${total} (${pct.toFixed(pct < 1 ? 2 : 1)}%)`;
const n = (x: number) => formatNumber(x, lang);
return `${n(dropped)} / ${n(total)} (${formatDecimal(pct, lang, pct < 1 ? 2 : 1)}%)`;
}
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
@@ -146,7 +152,7 @@ export function buildStatRows(s: PlexStatsInput): StatRow[] {
{
key: "bitrate",
labelKey: "plex.player.stats.bitrate",
value: fmtBitrate(s.measuredBps),
value: fmtBitrate(s.measuredBps, s.lang),
},
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
);
@@ -159,9 +165,13 @@ export function buildStatRows(s: PlexStatsInput): StatRow[] {
{
key: "frames",
labelKey: "plex.player.stats.frames",
value: fmtFrames(s.droppedFrames, s.totalFrames),
value: fmtFrames(s.droppedFrames, s.totalFrames, s.lang),
},
{
key: "buffer",
labelKey: "plex.player.stats.buffer",
value: fmtBufferSecs(s.bufferAheadS, s.lang),
},
{ key: "buffer", labelKey: "plex.player.stats.buffer", value: fmtBufferSecs(s.bufferAheadS) },
{ key: "offset", labelKey: "plex.player.stats.offset", value: fmtClock(s.sessionStartS) },
{
key: "position",
+26 -1
View File
@@ -26,6 +26,31 @@ function wrapper({ children }: { children: ReactNode }) {
}
describe("the tag catalog", () => {
it("comes back in the reader's alphabet by the shown name, category by category", async () => {
vi.spyOn(api, "tags").mockResolvedValue([
// the server's order: category, then the STORED English name
{ ...tag(1, "Education", true), category: "topic" },
{ ...tag(2, "Music", true), category: "topic" },
{ ...tag(3, "Travel", true), category: "topic" },
{ ...tag(4, "English", true), category: "language" },
{ ...tag(5, "Hungarian", true), category: "language" },
]);
setLanguage("hu");
try {
const { result } = renderHook(() => useTags(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data!.map((tg) => tg.name)).toEqual([
"Oktatás",
"Utazás",
"Zene",
"Angol",
"Magyar",
]);
} finally {
setLanguage("en");
}
});
it("names an auto-tag in the reader's language, a user's tag as typed", async () => {
vi.spyOn(api, "tags").mockResolvedValue([
tag(1, "Health & Fitness", true),
@@ -39,8 +64,8 @@ describe("the tag catalog", () => {
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data!.map((tg) => tg.name)).toEqual([
"Egészség és fitnesz",
"Magyar",
"Gaming",
"Magyar",
"Somethingnew",
]);
} finally {
+17 -3
View File
@@ -1,20 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag } from "./api";
import { compareText } from "./collate";
import { qk } from "./queryKeys";
import { vocab } from "./vocab";
/** The tag catalog, every reader's ONE way to it: a system (auto) tag is stored under its English name
* ("Health & Fitness", "Hungarian") and named here in the reader's language (E30, decision D1 A — the
* data stays language-neutral, filters and saved views hold ids). A user's own tag is theirs, as typed.
* The list comes back in the reader's alphabetical order, by the names they see.
* Naming it once, at the source, is what reaches every chip, column, menu and sort that shows one. */
export function useTags(opts: { enabled?: boolean; staleTime?: number } = {}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
return useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
...opts,
select: (tags: Tag[]) =>
tags.map((tg) => (tg.system ? { ...tg, name: vocab(t, "autoTag", tg.name) } : tg)),
select: (tags: Tag[]) => {
const named = tags.map((tg) =>
tg.system ? { ...tg, name: vocab(t, "autoTag", tg.name) } : tg,
);
// The server orders by category, then by the STORED (English) name; re-sorted by the name the
// reader sees, in their alphabet, keeping the server's category order (E30 S3).
const rank = new Map<string, number>();
for (const tg of named) if (!rank.has(tg.category)) rank.set(tg.category, rank.size);
return named.sort(
(a, b) =>
rank.get(a.category)! - rank.get(b.category)! || compareText(a.name, b.name, lang),
);
},
});
}