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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AppNotification } from "./api";
|
||||
import type { Notification as ClientNotif } from "./notifications";
|
||||
|
||||
// The notification center's order, in ONE rule for both sources. It used to be two fixed sections,
|
||||
// the server's above this browser's — so an admin's "access requests waiting" sat under up to a
|
||||
// hundred server rows, week-old read "Download ready" notices among them. Now what asks for the
|
||||
// reader comes first, then everything else as one time-ordered stream, and read rows older than a
|
||||
// week fold away (kept, not deleted).
|
||||
|
||||
export type InboxEntry =
|
||||
| { src: "server"; n: AppNotification; at: number }
|
||||
| { src: "client"; n: ClientNotif; at: number };
|
||||
|
||||
export interface InboxOrder {
|
||||
/** Unread, or waiting on an action: newest first. Never folded, whatever its age. */
|
||||
needsYou: InboxEntry[];
|
||||
/** The rest from the last week (or of no known date), newest first. */
|
||||
recent: InboxEntry[];
|
||||
/** Read rows older than `FOLD_AFTER_MS`, newest first — shown only on request. */
|
||||
earlier: InboxEntry[];
|
||||
}
|
||||
|
||||
export const FOLD_AFTER_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
// A row of no known date (a server row stored without one) sorts last and never folds: its age is
|
||||
// unknown, and hiding it on a guess is how a row goes missing.
|
||||
const NO_DATE = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function serverAt(n: AppNotification): number {
|
||||
const at = n.created_at ? Date.parse(n.created_at) : NaN;
|
||||
return Number.isNaN(at) ? NO_DATE : at;
|
||||
}
|
||||
|
||||
/** A row's key across both sources: their ids are separate sequences, and they share one list now. */
|
||||
export const keyOf = (src: InboxEntry["src"], id: number): string => `${src}:${id}`;
|
||||
export const entryKey = (e: InboxEntry): string => keyOf(e.src, e.n.id);
|
||||
|
||||
/** Whether a client notice asks for the reader by what it is: an access-requests nudge for as long
|
||||
* as it exists (its producer removes it when nothing is pending), a notice that waits on an
|
||||
* interaction until it is dealt with. */
|
||||
const clientAsks = (n: ClientNotif): boolean =>
|
||||
n.meta?.kind === "access-requests" || (n.requiresInteraction && !n.dismissed);
|
||||
|
||||
export function orderInbox(
|
||||
server: readonly AppNotification[],
|
||||
client: readonly ClientNotif[],
|
||||
/** `held`: the rows that asked earlier in this visit (entry keys). They keep their place once read —
|
||||
* a row marked read must not jump to another list under the reader, taking the keyboard focus to
|
||||
* <body> as it remounts there — and they are how a client notice stays up at all: opening the
|
||||
* center marks every client notice read, so "unread" alone would empty the band as it renders. */
|
||||
{ now, held }: { now: number; held: ReadonlySet<string> },
|
||||
): InboxOrder {
|
||||
const out: InboxOrder = { needsYou: [], recent: [], earlier: [] };
|
||||
const place = (e: InboxEntry, asks: boolean) => {
|
||||
if (asks) out.needsYou.push(e);
|
||||
else if (e.at !== NO_DATE && now - e.at > FOLD_AFTER_MS) out.earlier.push(e);
|
||||
else out.recent.push(e);
|
||||
};
|
||||
for (const n of server)
|
||||
place({ src: "server", n, at: serverAt(n) }, !n.read || held.has(keyOf("server", n.id)));
|
||||
for (const n of client)
|
||||
place(
|
||||
{ src: "client", n, at: n.ts },
|
||||
!n.read || held.has(keyOf("client", n.id)) || clientAsks(n),
|
||||
);
|
||||
// Newest first; equal times keep their source's own order (Array.sort is stable).
|
||||
const newest = (a: InboxEntry, b: InboxEntry) => (b.at === a.at ? 0 : b.at > a.at ? 1 : -1);
|
||||
out.needsYou.sort(newest);
|
||||
out.recent.sort(newest);
|
||||
out.earlier.sort(newest);
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user