feat(notifications): what asks for you comes first, then one stream; old read rows fold

The center showed two fixed sections, the server's above this browser's, so an
admin's "access requests waiting" sat under every server row, week-old read
"Download ready" notices among them. One order now, for both sources
(lib/inboxOrder): "Needs you" (unread, an access-requests nudge, a notice
waiting on an interaction), then everything else newest first, and read rows
older than a week fold under "Earlier (n)", closed by default.

A row that asked in this visit keeps its place once read: marking it read no
longer moves it to another list under the reader. Mark all read empties the
band, the client notices of the visit included (the server rows read at once,
not when the write lands). Marking a row read from its button hands the focus
to the row instead of dropping it on <body> as the button goes.
This commit is contained in:
2026-10-28 20:01:21 +01:00
parent 0d410592e2
commit f49dc6532f
6 changed files with 507 additions and 52 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { FOLD_AFTER_MS, entryKey, orderInbox, type InboxOrder } from "./inboxOrder";
import type { AppNotification } from "./api";
import type { Notification as ClientNotif } from "./notifications";
const NOW = Date.parse("2026-10-28T12:00:00Z");
const ago = (ms: number) => NOW - ms;
const H = 3600 * 1000;
const D = 24 * H;
const server = (id: number, at: number | null, read: boolean) =>
({
id,
type: "download_done",
title: "t",
body: null,
data: null,
read,
dismissed: false,
created_at: at === null ? null : new Date(at).toISOString(),
}) as AppNotification;
const client = (id: number, at: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "info",
message: "m",
requiresInteraction: false,
ts: at,
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as ClientNotif;
const keys = (o: InboxOrder) => ({
needsYou: o.needsYou.map(entryKey),
recent: o.recent.map(entryKey),
earlier: o.earlier.map(entryKey),
});
const order = (s: AppNotification[], c: ClientNotif[], held: ReadonlySet<string> = new Set()) =>
keys(orderInbox(s, c, { now: NOW, held }));
describe("orderInbox", () => {
it("puts a waiting access-requests nudge above every server row, read or not", () => {
const o = order(
[server(1, ago(1 * H), false), server(2, ago(1 * D), true)],
[client(1, ago(5 * 60 * 1000), { meta: { kind: "access-requests" } })],
);
expect(o.needsYou).toEqual(["client:1", "server:1"]);
expect(o.recent).toEqual(["server:2"]);
});
it("an old unread row still asks: it never folds or drifts below newer read ones", () => {
const o = order([server(1, ago(30 * D), false), server(2, ago(1 * H), true)], []);
expect(o.needsYou).toEqual(["server:1"]);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual([]);
});
it("merges both sources into one stream, newest first", () => {
const o = order(
[server(1, ago(3 * H), true), server(2, ago(1 * H), true)],
[client(7, ago(2 * H)), client(8, ago(4 * H))],
);
expect(o.recent).toEqual(["server:2", "client:7", "server:1", "client:8"]);
});
it("folds read rows older than a week, and only those", () => {
const o = order(
[server(1, ago(FOLD_AFTER_MS + H), true), server(2, ago(FOLD_AFTER_MS - H), true)],
[client(3, ago(9 * D))],
);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual(["server:1", "client:3"]);
});
it("keeps a row that asked earlier in the visit in its place once read, from either source", () => {
// Opening the center marks the client notices read: the band must not empty as it renders; and
// a server row marked read must not jump lists under the reader.
const o = order(
[server(1, ago(30 * D), true), server(2, ago(3 * H), true)],
[client(4, ago(2 * H)), client(5, ago(1 * H))],
new Set(["client:4", "server:1"]),
);
expect(o.needsYou).toEqual(["client:4", "server:1"]);
expect(o.recent).toEqual(["client:5", "server:2"]);
expect(o.earlier).toEqual([]);
});
it("an unread client notice (raised while the center is open) asks", () => {
const o = order([], [client(6, ago(H), { read: false })]);
expect(o.needsYou).toEqual(["client:6"]);
});
it("a notice waiting on an interaction asks until it is dealt with", () => {
const o = order(
[],
[
client(1, ago(H), { requiresInteraction: true, dismissed: false }),
client(2, ago(H), { requiresInteraction: true, dismissed: true }),
],
);
expect(o.needsYou).toEqual(["client:1"]);
expect(o.recent).toEqual(["client:2"]);
});
it("a server row of no known date sorts last and never folds", () => {
const o = order([server(1, null, true), server(2, ago(30 * D), true)], [client(3, ago(H))]);
expect(o.recent).toEqual(["client:3", "server:1"]);
expect(o.earlier).toEqual(["server:2"]);
});
});