From ab17848515a05fc40240d65bfed518fba3f1f3c5 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 11 Jun 2026 03:48:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(viewer):=20sort=20the=20board=20sidebar=20?= =?UTF-8?q?by=20Name=20=C2=B7=20Created=20=C2=B7=20Updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When several agents push to several boards, there was no way to tell which board changed most recently — the sidebar listed boards in fixed alphabetical order. Add a 3-way sort toggle above the board list: - Name (A→Z, the previous default) - Created (newest first) - Updated (newest first) The choice is persisted in localStorage (default Name, so existing users see no change until they switch). Each row shows a muted "Xm ago" for the active key, refreshed in place every 60s; in Created/Updated modes the most-recent board wears a "latest" tag. Data: - Payload gains a stable `createdAt`. Store.set resolves it once (incoming ?? existing ?? ts) and returns the canonical stored payload, so created_at is set at first push and preserved across every later update, and survives reloads via persistence. Pushers can't forge it (asPayload never reads it from the request body). - The four `update` broadcasts now send the stored payload, so both timestamps reach the client on every event; `createdAt` is also added to GET /list. - `sortBoards` is a pure, DOM-free helper in state.ts (name asc / created desc / updated desc, stable name tie-break), unit-tested directly. - The demo seed staggers each board's created/updated time so the toggle is demonstrable on /w/demo/. Tests: state.test.ts covers the createdAt semantics + sortBoards; a new board-sort.e2e.mjs drives the toggle in a browser (order per mode, the "latest" tag, and persistence across reload) and is chained into the e2e CI script. --- packages/viewer/e2e/board-sort.e2e.mjs | 114 +++++++++++++++++++++++++ packages/viewer/package.json | 4 +- packages/viewer/src/client/style.css | 19 +++++ packages/viewer/src/client/viewer.ts | 88 ++++++++++++++++++- packages/viewer/src/seed.ts | 13 ++- packages/viewer/src/server.ts | 20 ++--- packages/viewer/src/state.ts | 36 +++++++- packages/viewer/test/state.test.ts | 55 +++++++++++- 8 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 packages/viewer/e2e/board-sort.e2e.mjs diff --git a/packages/viewer/e2e/board-sort.e2e.mjs b/packages/viewer/e2e/board-sort.e2e.mjs new file mode 100644 index 0000000..ba41bce --- /dev/null +++ b/packages/viewer/e2e/board-sort.e2e.mjs @@ -0,0 +1,114 @@ +// End-to-end test for the sidebar board-sort toggle (Name / Created / Updated). Boots dist/server.js, +// pushes three boards so that name order, created order, and updated order are all DIFFERENT, then +// drives the toggle in a real browser and asserts the row order + the "latest" tag + persistence. +// +// node e2e/board-sort.e2e.mjs (run `npm run build` first, or use `npm run test:e2e`) +// +// Exit 0 = all assertions passed, 1 = a failure. No network/cloud needed. +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VIEWER = join(HERE, ".."); +const PORT = Number(process.env.E2E_PORT ?? 8197); +const TOKEN = "e2e-token"; +const BASE = `http://127.0.0.1:${PORT}`; +const WSID = "sort"; +const U = `${BASE}/w/${WSID}/`; + +const results = []; +const ok = (n, c) => { results.push(c); console.log(`${c ? "PASS" : "FAIL"} ${n}`); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function push(agent) { + const r = await fetch(`${BASE}/w/${WSID}/push`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ project: "s", agent, type: "markdown", content: `# ${agent}`, description: agent }), + }); + if (!r.ok) throw new Error(`push ${agent} -> ${r.status}`); +} + +async function waitForServer(timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { const r = await fetch(`${BASE}/healthz`); if (r.ok) return; } catch { /* not up */ } + await sleep(200); + } + throw new Error("server did not start"); +} + +// Read the board rows' scope keys top-to-bottom (excludes the ★ Welcome home button, which is not +// in a .scope-row). +const order = (p) => p.$$eval("#scopes .scope-row .scope", (els) => els.map((e) => e.getAttribute("title"))); +// Click a sort segment by its visible label. +const clickSort = async (p, label) => { await p.locator(".scope-sort-btn", { hasText: label }).click(); await p.waitForTimeout(150); }; + +const srv = spawn("node", [join(VIEWER, "dist", "server.js")], { + env: { ...process.env, PORT: String(PORT), PUSH_TOKEN: TOKEN }, + stdio: ["ignore", "ignore", "inherit"], +}); + +let browser; +try { + await waitForServer(); + + // Push order sets createdAt; a re-push of bravo at the end makes it the newest UPDATED while it + // stays the oldest CREATED. Sleeps keep the ms-resolution timestamps strictly ordered. + await push("bravo"); await sleep(30); // created: oldest + await push("alpha"); await sleep(30); + await push("charlie"); await sleep(30); // created: newest + await push("bravo"); // updated: newest (createdAt preserved = oldest) + + // name : alpha, bravo, charlie (A→Z) + // created : charlie, alpha, bravo (newest-created first) + // updated : bravo, charlie, alpha (newest-updated first) + const byName = ["s/alpha", "s/bravo", "s/charlie"]; + const byCreated = ["s/charlie", "s/alpha", "s/bravo"]; + const byUpdated = ["s/bravo", "s/charlie", "s/alpha"]; + + browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] }); + const p = await browser.newPage({ viewport: { width: 1100, height: 800 } }); + const errors = []; + p.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); + p.on("pageerror", (e) => errors.push(String(e))); + await p.goto(U, { waitUntil: "domcontentloaded", timeout: 20000 }); + await p.waitForFunction(() => document.querySelectorAll("#scopes .scope-row").length === 3, null, { timeout: 15000 }); + + ok("sort toggle renders (3 segments)", (await p.locator(".scope-sort-btn").count()) === 3); + ok("each board row shows a relative-time label", (await p.locator("#scopes .scope-row .scope-ago").count()) === 3); + + // Default is Name (alphabetical). + ok("default order is by name (A→Z)", JSON.stringify(await order(p)) === JSON.stringify(byName)); + ok("no 'latest' tag in name mode", (await p.locator("#scopes .scope-latest").count()) === 0); + + await clickSort(p, "Created"); + ok("created mode → newest-created first", JSON.stringify(await order(p)) === JSON.stringify(byCreated)); + ok("created mode tags exactly one 'latest' (the top row)", (await p.locator("#scopes .scope-latest").count()) === 1); + ok("created 'latest' tag is on the first row", await p.evaluate(() => + document.querySelector("#scopes .scope-row .scope")?.querySelector(".scope-latest") !== null)); + + await clickSort(p, "Updated"); + ok("updated mode → newest-updated first", JSON.stringify(await order(p)) === JSON.stringify(byUpdated)); + ok("updated mode tags exactly one 'latest'", (await p.locator("#scopes .scope-latest").count()) === 1); + + // Persistence: reload keeps the chosen mode (localStorage) and its order. + await p.reload({ waitUntil: "domcontentloaded" }); + await p.waitForFunction(() => document.querySelectorAll("#scopes .scope-row").length === 3, null, { timeout: 15000 }); + ok("chosen sort persists across reload (Updated still active)", + (await p.locator('.scope-sort-btn[aria-pressed="true"]').textContent()) === "Updated"); + ok("persisted order is still by updated", JSON.stringify(await order(p)) === JSON.stringify(byUpdated)); + + ok("no console/page errors", errors.length === 0 || (console.log(" errors:", errors.slice(0, 5)), false)); +} catch (e) { + ok(`harness error: ${e.message}`, false); +} finally { + if (browser) await browser.close(); + srv.kill("SIGTERM"); +} + +const failed = results.filter((r) => !r).length; +console.log(`\n=== ${results.length - failed}/${results.length} passed (board-sort e2e) ===`); +process.exit(failed ? 1 : 0); diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 514ec41..7aa1b5d 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -25,8 +25,8 @@ "start": "node dist/server.js", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npm run build && node e2e/rich.e2e.mjs", - "test:e2e:nobuild": "node e2e/rich.e2e.mjs", + "test:e2e": "npm run build && node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs", + "test:e2e:nobuild": "node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs", "test:overlap": "npm run build && node e2e/overlap.e2e.mjs", "test:overlap:nobuild": "node e2e/overlap.e2e.mjs", "test:stress": "npm run build && node e2e/stress.e2e.mjs" diff --git a/packages/viewer/src/client/style.css b/packages/viewer/src/client/style.css index bce9de9..e6b0420 100644 --- a/packages/viewer/src/client/style.css +++ b/packages/viewer/src/client/style.css @@ -137,6 +137,25 @@ body { .scope-home { font-weight: 600; margin-bottom: 6px; border-bottom: 1px solid var(--border); border-radius: 6px; } .empty-hint { color: var(--muted); font-size: 12px; padding: 6px 8px; margin: 0; } +/* Sort toggle (Name / Created / Updated): a compact segmented control above the board list. */ +.scope-sort { display: flex; gap: 2px; margin: 0 0 6px; padding: 2px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; } +.scope-sort-btn { + flex: 1; min-height: 30px; padding: 4px 6px; + background: transparent; border: 1px solid transparent; border-radius: 4px; + color: var(--muted); font: inherit; font-size: 11px; cursor: pointer; + transition: background .15s ease, color .15s ease; +} +.scope-sort-btn:hover { color: var(--fg); } +.scope-sort-btn[aria-pressed="true"] { background: var(--active); color: var(--fg); } +.scope-sort-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +/* Per-board recency line under the agent name: "latest" tag + relative time. */ +.scope-time { display: flex; align-items: center; gap: 5px; margin-top: 2px; font-size: 10px; line-height: 1.3; color: var(--muted); } +.scope-ago { white-space: nowrap; } +.scope-latest { + flex-shrink: 0; padding: 0 5px; border-radius: 999px; font-size: 9px; font-weight: 600; + text-transform: uppercase; letter-spacing: .3px; color: var(--bg); background: var(--accent); +} + main { flex: 1; display: flex; flex-direction: column; min-width: 0; } header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); } /* ≥44px hit area (iPad tap target); the svg stays ~16px, centered. */ diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index c32dc0f..9d9e9b5 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -4,8 +4,9 @@ import { INTRO_PANES } from "./intro.js"; import { setActiveScope } from "./interact.js"; import { initConsole, setConsoleScope, onInboxEvent, onInboxRead, onSuggest, resyncConsole } from "./console.js"; import type { PatchOp } from "../flow-patch.js"; +import { sortBoards, type BoardSort } from "../state.js"; -interface Payload { project: string; agent: string; type: string; content: string; ts: number; } +interface Payload { project: string; agent: string; type: string; content: string; ts: number; createdAt?: number; } const key = (p: { project: string; agent: string }) => `${p.project}/${p.agent}`; // Two surfaces share this bundle: the normal workspace `/w//` and a single-board SHARE view @@ -48,6 +49,22 @@ const DEMO_INTROS: Record = { }; const $ = (id: string) => document.getElementById(id)!; + +// How the board list is ordered: by Name (A→Z, the historical default), Created, or Updated (both +// newest-first). Persisted so the choice survives reloads. The labels for the sidebar toggle. +const SORT_KEY = "tc-board-sort"; +const SORT_LABELS: Record = { name: "Name", created: "Created", updated: "Updated" }; +function readBoardSort(): BoardSort { + try { const v = localStorage.getItem(SORT_KEY); if (v === "name" || v === "created" || v === "updated") return v; } catch { /* */ } + return "name"; +} +let boardSort: BoardSort = readBoardSort(); +function setBoardSort(mode: BoardSort): void { + boardSort = mode; + try { localStorage.setItem(SORT_KEY, mode); } catch { /* */ } + renderSidebar(); +} + // "Follow the focused board" is a toggle button (aria-pressed). Track the mode in JS and mirror it // onto the button, instead of reading a checkbox. let following = true; @@ -129,15 +146,23 @@ function renderSidebar() { home.textContent = "★ Welcome"; home.onclick = () => selectIntro(); nav.appendChild(home); - const keys = [...scopes.keys()].sort(); - if (keys.length === 0) { + const ordered = sortBoards( + [...scopes].map(([key, p]) => ({ key, ts: p.ts, createdAt: p.createdAt })), + boardSort, + ).map((e) => e.key); + if (ordered.length === 0) { const hint = document.createElement("p"); hint.className = "empty-hint"; hint.textContent = "no boards yet"; nav.appendChild(hint); return; } - for (const k of keys) { + // Offer the sort toggle only once there's something to sort. In created/updated modes the first + // row is the most-recently-touched board, so flag it "latest". + if (ordered.length >= 2) nav.appendChild(buildSortToggle()); + const latestKey = boardSort === "name" ? "" : ordered[0]; + for (const k of ordered) { + const p = scopes.get(k); const btn = document.createElement("button"); btn.type = "button"; btn.className = "scope" + (k === current ? " active" : ""); @@ -160,6 +185,27 @@ function renderSidebar() { a.className = "scope-agent"; a.textContent = agent; label.appendChild(a); + // Recency line: the time matching the active sort key (created → createdAt; name/updated → ts), + // kept fresh in place by refreshSidebarTimes(). Tooltip carries both absolute stamps. The single + // most-recent board (created/updated modes) wears a "latest" tag. + if (p) { + const stamp = boardSort === "created" ? (p.createdAt ?? p.ts) : p.ts; + const meta = document.createElement("span"); + meta.className = "scope-time"; + meta.title = `created ${absTime(p.createdAt ?? p.ts)} · updated ${absTime(p.ts)}`; + if (k === latestKey) { + const tag = document.createElement("span"); + tag.className = "scope-latest"; + tag.textContent = "latest"; + meta.appendChild(tag); + } + const ago = document.createElement("span"); + ago.className = "scope-ago"; + ago.dataset.t = String(stamp); + ago.textContent = relTime(stamp); + meta.appendChild(ago); + label.appendChild(meta); + } btn.title = k; // full scope on hover btn.appendChild(label); @@ -221,6 +267,40 @@ function renderSidebar() { nav.appendChild(all); } +/** The Name / Created / Updated segmented control shown above the board list. */ +function buildSortToggle(): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "scope-sort"; + wrap.setAttribute("role", "group"); + wrap.setAttribute("aria-label", "Sort boards"); + for (const mode of ["name", "created", "updated"] as const) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "scope-sort-btn"; + b.textContent = SORT_LABELS[mode]; + b.setAttribute("aria-pressed", boardSort === mode ? "true" : "false"); + b.title = mode === "name" ? "Sort A→Z" : `Sort by ${mode} time (newest first)`; + b.onclick = () => setBoardSort(mode); + wrap.appendChild(b); + } + return wrap; +} + +/** Absolute timestamp for the recency tooltip. */ +function absTime(ts: number): string { + if (!ts) return "unknown"; + try { return new Date(ts).toLocaleString(); } catch { return ""; } +} + +// Keep the "Xm ago" labels current while the screen sits idle (no push to trigger a re-render). +// Updates text in place — no DOM rebuild, so focus/scroll are untouched. +function refreshSidebarTimes(): void { + document.querySelectorAll(".scope-ago[data-t]").forEach((el) => { + el.textContent = relTime(Number(el.dataset.t)); + }); +} +setInterval(refreshSidebarTimes, 60_000); + function fitScale() { const pre = $("diagram"); const stage = $("stage"); pre.style.transform = "none"; diff --git a/packages/viewer/src/seed.ts b/packages/viewer/src/seed.ts index 22535de..1b6595c 100644 --- a/packages/viewer/src/seed.ts +++ b/packages/viewer/src/seed.ts @@ -55,8 +55,14 @@ const SEED: SeedScope[] = [ /** Populate the demo workspace with its full-scope fixtures (no-op if already populated). */ export function seedDemo(store: Store): void { if (store.list(DEMO_WSID).length > 0) return; - const ts = Date.now(); - for (const s of SEED) { + const now = Date.now(); + const HOUR = 3_600_000, MIN = 60_000; + SEED.forEach((s, i) => { + // Demo-only: stagger the timestamps so the sidebar's Name / Created / Updated sort toggle visibly + // reorders the list. `createdAt` ages with seed order (first seeded = oldest); `ts` (updated) is + // permuted ((i*5) mod 12 is a permutation since gcd(5,12)=1) so updated order differs from both. + const createdAt = now - (SEED.length - i) * 5 * HOUR; + const ts = now - ((i * 5) % SEED.length) * 23 * MIN; const p: Payload = { project: s.project, agent: s.agent, @@ -64,7 +70,8 @@ export function seedDemo(store: Store): void { description: s.description, content: JSON.stringify(s.content), ts, + createdAt, }; store.set(DEMO_WSID, p); - } + }); } diff --git a/packages/viewer/src/server.ts b/packages/viewer/src/server.ts index a891557..e4dcaed 100644 --- a/packages/viewer/src/server.ts +++ b/packages/viewer/src/server.ts @@ -440,9 +440,9 @@ export function createViewerServer(opts: ServerOpts) { const invalid = validateContent(at.type, at.content); if (invalid) return send(res, 400, invalid); const next: Payload = { project: b.project, agent: b.agent, type: at.type, content: at.content, description: at.description || `restored v${b.seq}`, ts: Date.now() }; - store.set(wsid, next); + const stored = store.set(wsid, next); await persistSave(wsid); - fanout(wsid, { event: "update", data: next }); + fanout(wsid, { event: "update", data: stored }); await recordAction(wsid, scope, { kind: "restore", ts: next.ts, summary: `restored v${b.seq}`, type: next.type, content: next.content, description: next.description }); return send(res, 204); } @@ -519,7 +519,7 @@ export function createViewerServer(opts: ServerOpts) { const invalid = validateContent(cur.type, content); if (invalid) return send(res, 400, invalid); const next: Payload = { ...cur, content, ts: Date.now() }; - store.set(wsid, next); + const stored = store.set(wsid, next); await persistSave(wsid); // Broadcast the merged payload PLUS an equivalent `setChecked` op (parsed from the validated // ref). A viewer already showing this component applies it IN PLACE (React diff, no remount — @@ -528,7 +528,7 @@ export function createViewerServer(opts: ServerOpts) { const sl = (b.ref as string).indexOf("/"); const item = (b.ref as string).slice(sl + 1); const ops = [{ op: "setChecked", id: (b.ref as string).slice(0, sl), item, value: b.value }]; - fanout(wsid, { event: "update", data: { ...next, ops } }); + fanout(wsid, { event: "update", data: { ...stored, ops } }); await recordAction(wsid, `${b.project}/${b.agent}`, { kind: "interact", ts: next.ts, summary: `${b.value ? "ticked" : "unticked"} ${item}`, ops }); return send(res, 204); } @@ -607,9 +607,9 @@ export function createViewerServer(opts: ServerOpts) { if (strict && sev && meetsThreshold(sev, strict)) return send(res, 422, JSON.stringify({ rejected: true, reason: "geometry", maxSeverity: sev, findings, warnings }), "application/json"); if (!(await hydratedForWrite(wsid))) return send(res, 503, "state backend unavailable; refusing to modify to avoid data loss"); - store.set(wsid, p); + const stored = store.set(wsid, p); await persistSave(wsid); - fanout(wsid, { event: "update", data: p }); + fanout(wsid, { event: "update", data: stored }); await recordAction(wsid, `${p.project}/${p.agent}`, { kind: "push", ts: p.ts, summary: p.description, type: p.type, content: p.content, description: p.description }); setInboxHeader(res, wsid, `${p.project}/${p.agent}`); // Non-fatal: the push succeeded; findings are signalled back so the pusher can fix or weigh. @@ -652,7 +652,7 @@ export function createViewerServer(opts: ServerOpts) { const invalid = validateContent(cur.type, content); if (invalid) return send(res, 400, invalid); const next: Payload = { ...cur, content, ts: Date.now() }; - store.set(wsid, next); + const stored = store.set(wsid, next); await persistSave(wsid); // Broadcast the full merged payload (any client renders it as a normal update) PLUS the `ops`, // so a client already showing this view can apply them IN PLACE: flow updates nodes without @@ -660,7 +660,7 @@ export function createViewerServer(opts: ServerOpts) { // tab/scroll/focus); panes delegates a `patchPane` to just that sub-pane (the other panes stay // mounted). A client that can't apply an op falls back to a full re-render. The stored payload // (above) stays clean — `ops` rides only the wire. - fanout(wsid, { event: "update", data: { ...next, ops: b.ops } }); + fanout(wsid, { event: "update", data: { ...stored, ops: b.ops } }); await recordAction(wsid, `${b.project}/${b.agent}`, { kind: "patch", ts: next.ts, summary: summarizeOps(b.ops), ops: b.ops }); setInboxHeader(res, wsid, `${b.project}/${b.agent}`); const warnings = cur.type === "flow" ? geometryWarnings("flow", content) : []; @@ -672,8 +672,8 @@ export function createViewerServer(opts: ServerOpts) { // `list` = lightweight catalog for discovery; `state` = full spec(s) to pull and iterate on. if (req.method === "GET" && action === "list") { await hydratedForRead(wsid); - const catalog = store.list(wsid).map(({ project, agent, type, description, ts }) => ({ - project, agent, type, description, ts, + const catalog = store.list(wsid).map(({ project, agent, type, description, ts, createdAt }) => ({ + project, agent, type, description, ts, createdAt, })); return send(res, 200, JSON.stringify(catalog), "application/json"); } diff --git a/packages/viewer/src/state.ts b/packages/viewer/src/state.ts index 60e46b0..3399833 100644 --- a/packages/viewer/src/state.ts +++ b/packages/viewer/src/state.ts @@ -4,12 +4,32 @@ export interface Payload { type: string; content: string; description: string; // short human/agent-readable summary, shown in the catalog (GET /list) - ts: number; + ts: number; // last-update time (updated_at) — bumped on every push/patch/interact/restore + createdAt?: number; // first-push time (created_at); set once by Store.set and preserved across updates } export const scopeKey = (project: string, agent: string): string => `${project}/${agent}`; +/** How the board list is ordered in the sidebar. */ +export type BoardSort = "name" | "created" | "updated"; + +/** + * Pure, DOM-free board ordering used by the sidebar (and unit-tested directly). `name` sorts by scope + * key ascending (the historical default); `created`/`updated` sort newest-first by `createdAt`/`ts`, + * falling back to `ts` when `createdAt` is absent (legacy payloads). Ties break on `key` ascending so + * the order is stable and never shuffles for equal timestamps. + */ +export function sortBoards( + entries: T[], + mode: BoardSort, +): T[] { + const byKey = (a: T, b: T) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0); + if (mode === "name") return [...entries].sort(byKey); + const stamp = mode === "created" ? (e: T) => e.createdAt ?? e.ts : (e: T) => e.ts; + return [...entries].sort((a, b) => stamp(b) - stamp(a) || byKey(a, b)); +} + /** * One entry in a board's action history (per-board append-only log). `push`/`restore` (and a delta * promoted on trim) are KEYFRAMES carrying full `content` (+`type`/`description`); `patch`/`interact` @@ -246,13 +266,23 @@ export class InboxStore { export class Store { private ws = new Map>(); - set(wsid: string, p: Payload): void { + /** + * Store the latest payload for a scope and return the canonical stored object. `createdAt` is + * resolved once and preserved: an incoming value wins (a hydrated payload from persistence), then + * the existing entry's value (so updates keep the original first-push time), else `ts` stamps a + * fresh creation. Pushers can't forge it — `asPayload` never reads `createdAt` from the request. + */ + set(wsid: string, p: Payload): Payload { let scopes = this.ws.get(wsid); if (!scopes) { scopes = new Map(); this.ws.set(wsid, scopes); } - scopes.set(scopeKey(p.project, p.agent), p); + const key = scopeKey(p.project, p.agent); + const createdAt = p.createdAt ?? scopes.get(key)?.createdAt ?? p.ts; + const stored: Payload = { ...p, createdAt }; + scopes.set(key, stored); + return stored; } list(wsid: string): Payload[] { diff --git a/packages/viewer/test/state.test.ts b/packages/viewer/test/state.test.ts index 3d7b586..7424c7a 100644 --- a/packages/viewer/test/state.test.ts +++ b/packages/viewer/test/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Store, StatusStore, InboxStore, scopeKey } from "../src/state.js"; +import { Store, StatusStore, InboxStore, scopeKey, sortBoards } from "../src/state.js"; describe("Store", () => { it("keys scopes by project/agent", () => { @@ -22,6 +22,59 @@ describe("Store", () => { s.set("w1", { project: "p", agent: "a", type: "text", content: "x", ts: 1 }); expect(s.list("w2")).toEqual([]); }); + + it("stamps createdAt on first set and returns the stored payload", () => { + const s = new Store(); + const stored = s.set("w1", { project: "p", agent: "a", type: "text", content: "x", ts: 100 }); + expect(stored.createdAt).toBe(100); // first push → created == ts + expect(s.list("w1")[0].createdAt).toBe(100); + }); + + it("preserves createdAt across updates (updated_at moves, created_at doesn't)", () => { + const s = new Store(); + s.set("w1", { project: "p", agent: "a", type: "text", content: "one", ts: 100 }); + const after = s.set("w1", { project: "p", agent: "a", type: "text", content: "two", ts: 250 }); + expect(after.createdAt).toBe(100); // carried forward + expect(after.ts).toBe(250); // bumped + }); + + it("honors an incoming createdAt (e.g. a payload hydrated from persistence)", () => { + const s = new Store(); + const stored = s.set("w1", { project: "p", agent: "a", type: "text", content: "x", ts: 999, createdAt: 42 }); + expect(stored.createdAt).toBe(42); + }); +}); + +describe("sortBoards", () => { + const E = [ + { key: "b/two", ts: 30, createdAt: 10 }, + { key: "a/one", ts: 20, createdAt: 50 }, + { key: "c/three", ts: 20, createdAt: 90 }, // ts ties with a/one + ]; + + it("name → key ascending", () => { + expect(sortBoards(E, "name").map((e) => e.key)).toEqual(["a/one", "b/two", "c/three"]); + }); + + it("updated → ts descending, ties broken by key", () => { + // ts: b/two=30, then a/one=20 & c/three=20 tie → name asc (a before c) + expect(sortBoards(E, "updated").map((e) => e.key)).toEqual(["b/two", "a/one", "c/three"]); + }); + + it("created → createdAt descending", () => { + expect(sortBoards(E, "created").map((e) => e.key)).toEqual(["c/three", "a/one", "b/two"]); + }); + + it("created falls back to ts when createdAt is absent", () => { + const legacy = [{ key: "x", ts: 5 }, { key: "y", ts: 99 }]; + expect(sortBoards(legacy, "created").map((e) => e.key)).toEqual(["y", "x"]); + }); + + it("does not mutate the input array", () => { + const input = [...E]; + sortBoards(input, "updated"); + expect(input).toEqual(E); + }); }); describe("StatusStore", () => {