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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user