feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied

- Nav rail page rows are real links (/?page=<id>): a middle-click or
  Ctrl-click opens the page in a new tab; a plain click stays in-app.
- A fresh channel page shows the unwatched videos; a picked watch state
  is a remembered preference (channelShow).
- Users: the role, suspend and delete move into the row's one menu
  (the "…", right-click, Shift+F10); locked ones keep their reason.
- Audit log: back-to-back successful runs of one job fold into one row
  that opens to the earlier ones; a failed run reads in the danger colour.
- DESIGN.md decision log: the E31 picks.
This commit is contained in:
2026-10-01 22:51:38 +02:00
parent ab146211fa
commit 87d71e3762
23 changed files with 542 additions and 98 deletions
+60
View File
@@ -0,0 +1,60 @@
// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs
// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run.
import { describe, expect, it } from "vitest";
import type { AuditRow } from "./api";
import { foldJobRuns } from "./auditGroups";
let nextId = 1;
const run = (job: string, status = "ok"): AuditRow => ({
id: nextId++,
created_at: null,
actor: null,
action: "job_run",
target_type: "job",
target_id: job,
summary: null,
before: null,
after: { status },
});
const admin = (): AuditRow => ({ ...run("x"), action: "config_set", target_type: "config" });
const shape = (rows: ReturnType<typeof foldJobRuns>) =>
rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`);
describe("folding the job runs", () => {
it("folds back-to-back successful runs of one job, newest first", () => {
const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")];
const out = foldJobRuns(rows);
expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]);
expect(out[0]!.id).toBe(rows[0]!.id);
expect(out[0]!.runs!.map((r) => r.id)).toEqual(rows.slice(0, 3).map((r) => r.id));
});
it("never folds a failed or skipped run, and lets it break the run", () => {
const rows = [run("rss"), run("rss", "error"), run("rss", "error"), run("rss"), run("rss")];
expect(shape(foldJobRuns(rows))).toEqual([
"rss:ok×1",
"rss:error×1",
"rss:error×1",
"rss:ok×2",
]);
expect(shape(foldJobRuns([run("rss", "skipped"), run("rss", "skipped")]))).toEqual([
"rss:skipped×1",
"rss:skipped×1",
]);
});
it("keeps an admin's own action between two runs, and the runs apart", () => {
expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([
"enrich:ok×1",
"x:ok×1",
"enrich:ok×1",
]);
});
it("leaves the rows it was given as they were", () => {
const rows = [run("enrich"), run("enrich")];
foldJobRuns(rows);
expect("runs" in rows[0]!).toBe(false);
});
});