fix(ui,i18n): E30 review round 1 — audit rows in the reader's language, one HU name per thing

- audit log: a job run named from its result data (nested phases too), a skipped run says so, a
  secret's reset reads as a reset, choices named as Configuration names them, interval / batch /
  counts localized; the inbox shares the same jobRunText
- HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is
  behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last
  "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names
- contract: every skipped / reason word a job returns is named in both languages
- person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText
  (ui) shared with the collection page, whose short summary lost its dead More
- a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an
  invite's date is in the admin's language; the language-ordered lists are asked again once a
  language switch is saved (saveLanguage)
- poster grids' column minimum in rem; lint refuses minmax(Npx,…)
This commit is contained in:
2026-09-30 03:26:58 +02:00
parent ab963c81d2
commit fa55e01ef0
46 changed files with 624 additions and 187 deletions
+23 -18
View File
@@ -8,39 +8,44 @@ describe("placeByPriority", () => {
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
it("bubbles a raised channel up to the first row it now outranks", () => {
expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([
"a",
"b",
"c",
"e",
"d",
]);
expect(
ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")),
).toEqual(["a", "b", "c", "e", "d"]);
});
it("sinks a lowered channel below every row it no longer outranks", () => {
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([
"b",
"c",
"a",
"d",
"e",
]);
expect(
ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")),
).toEqual(["b", "c", "a", "d", "e"]);
});
it("orders equal priorities by title, case-folded, a missing title last", () => {
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]);
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]);
expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]);
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([
"x",
"y",
]);
});
it("leaves every other row where it was, even ones out of the server's order", () => {
const rows = [row("p", 0), row("q", 9), row("r", 2)];
// r lands before the first row it outranks; p and q keep their (unsorted) order.
expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]);
expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]);
});
it("returns an unknown id's list unchanged", () => {
expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list));
expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list));
});
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]);
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
expect(
ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")),
).toEqual(["u", "c", "x"]);
});
});
+12 -6
View File
@@ -1,3 +1,5 @@
import { compareText } from "./collate";
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
@@ -19,23 +21,27 @@ interface Ordered {
title?: string | null;
}
/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing
* title last). */
function before(a: Ordered, b: Ordered): boolean {
/** The server's order (`routes/channels.py`: priority desc, then the title in the reader's alphabet —
* the ICU collation of `app/collation.py`, which `compareText` shares — a missing title last). */
function before(a: Ordered, b: Ordered, lang: string): boolean {
if (a.priority !== b.priority) return a.priority > b.priority;
if (a.title == null) return false;
if (b.title == null) return true;
return a.title.toLowerCase() < b.title.toLowerCase();
return compareText(a.title, b.title, lang) < 0;
}
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
* other row left exactly where it was — the server's order, without a refetch that would reorder
* anything the reader did not touch. */
export function placeByPriority<T extends Ordered>(rows: readonly T[], id: string): T[] {
export function placeByPriority<T extends Ordered>(
rows: readonly T[],
id: string,
lang: string,
): T[] {
const moved = rows.find((r) => r.id === id);
if (!moved) return [...rows];
const rest = rows.filter((r) => r !== moved);
const at = rest.findIndex((r) => before(moved, r));
const at = rest.findIndex((r) => before(moved, r, lang));
rest.splice(at < 0 ? rest.length : at, 0, moved);
return rest;
}
+1 -1
View File
@@ -31,7 +31,7 @@ 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" }, "hu")).toBe(
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
"dúsítva: 0 · kihagyva: nincs TMDB API-kulcs",
);
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
});
+31
View File
@@ -37,3 +37,34 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
})
.join(" · ");
}
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
* and how the run ended, and its result in the reader's language — from the data when it rode along,
* else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the
* upstream's own error text (it is diagnostic). */
export function jobRunText(
t: TFunction,
run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown },
lang: string,
): { title: string; body: string | null } {
const job = t(`scheduler.jobs.${run.job_id}`, run.job_id);
const summary = typeof run.summary === "string" && run.summary ? run.summary : null;
const titleKey =
run.status === "error"
? "inbox.jobDone.titleError"
: run.status === "skipped"
? "inbox.jobDone.titleSkipped"
: "inbox.jobDone.titleOk";
let result: JobResult | null = null;
if (run.status !== "error") {
result =
run.result && typeof run.result === "object"
? (run.result as JobResult)
: (parseSummary(summary) ??
(run.status === "skipped" && summary ? { skipped: summary } : null));
}
return {
title: t(titleKey, { job }),
body: result ? jobResultText(t, result, lang) : summary,
};
}
+6
View File
@@ -29,6 +29,12 @@ describe("a px-frozen box utility", () => {
expect(font?.messages.some((m) => m.message.startsWith("px-frozen font size"))).toBe(true);
});
it("is refused as a grid column's minimum (E30 review)", async () => {
const mm = (n: string) => `grid-cols-[repeat(auto-fill,minmax(${n},1fr))]`;
expect(await refused(`export const g = "grid ${mm("150px")}";\n`)).toBe(1);
expect(await refused(`export const h = "grid ${mm("9.375rem")}";\n`)).toBe(0);
});
it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => {
expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0);
expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0);
+14
View File
@@ -161,3 +161,17 @@ export const qk = {
plexPerson: (name: string, lang?: string) =>
["plex-person", name, ...(lang ? [lang] : [])] as const,
};
/** The lists the SERVER puts in the reader's alphabet from the language stored in their preferences
* (backend `lang_for_user` → `app/collation.py`), so their keys carry no language. Once a language
* switch has been saved they are asked for again (App's `changeLanguage`): they re-sort, and a
* title-sorted feed never reads its next page under the other alphabet's cursor (E30 review). */
export const LANGUAGE_ORDERED = [
qk.feed(),
qk.channels(),
qk.discoveredChannels(),
qk.blockedChannels(),
qk.shareRecipients(),
qk.messageUsers(),
qk.plexCollections(),
] as const;
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// E30 review: the server orders some lists by the reader's STORED language (their keys carry none), so a
// switch must re-ask for them — after the save lands, not before (the server would sort in the old one).
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { api } from "./api";
import { LANGUAGE_ORDERED, qk } from "./queryKeys";
import { saveLanguage } from "./saveLanguage";
afterEach(() => vi.restoreAllMocks());
describe("saveLanguage", () => {
it("asks again for the server-ordered lists once the save has landed", async () => {
let land!: () => void;
vi.spyOn(api, "savePrefs").mockReturnValue(
new Promise<never>((r) => (land = r as () => void)) as never,
);
const qc = new QueryClient();
const asked = vi.spyOn(qc, "invalidateQueries");
const done = saveLanguage(qc, "hu");
await Promise.resolve();
expect(asked).not.toHaveBeenCalled(); // the server still sorts in the old language
land();
await done;
const keys = asked.mock.calls.map((c) => c[0]?.queryKey);
expect(keys).toEqual([...LANGUAGE_ORDERED]);
expect(keys).toContainEqual(qk.feed());
expect(keys).toContainEqual(qk.channels());
});
it("leaves the lists alone when the save fails", async () => {
vi.spyOn(api, "savePrefs").mockRejectedValue(new Error("offline"));
const qc = new QueryClient();
const asked = vi.spyOn(qc, "invalidateQueries");
await expect(saveLanguage(qc, "hu")).rejects.toThrow("offline");
expect(asked).not.toHaveBeenCalled();
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { QueryClient } from "@tanstack/react-query";
import { api } from "./api";
import { LANGUAGE_ORDERED } from "./queryKeys";
/** Store the reader's language choice on the server, then ask again for the lists the server orders
* by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet.
* Rejects with the save's error; the lists are left alone then. */
export async function saveLanguage(qc: QueryClient, code: string): Promise<void> {
await api.savePrefs({ language: code });
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
}