From 30c1405f72d245289d95e5bcfd615017ad36f4ea Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 09:42:42 +0200 Subject: [PATCH] feat(activity): derive audit rows from card patches --- ...-09-16-phase-1b-design-system-and-board.md | 20 +++--- src/lib/activity/diff.test.ts | 70 +++++++++++++++++++ src/lib/activity/diff.ts | 63 +++++++++++++++++ 3 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 src/lib/activity/diff.test.ts create mode 100644 src/lib/activity/diff.ts diff --git a/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md index b297729..1b0ef25 100644 --- a/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md +++ b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md @@ -1831,7 +1831,6 @@ import { BoardColumn } from "@/components/board/board-column"; import { CardTile } from "@/components/board/card-tile"; import { ClientCardControls } from "@/components/board/client-card-controls"; import { computeDropPosition } from "@/lib/board/move"; -import { pbBrowser } from "@/lib/pb/browser"; import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types"; function SortableCard({ @@ -1964,14 +1963,17 @@ export function Board({ ); setCards(optimistic); - try { - // The owner writes straight to PocketBase — the owner-only write rule - // from Plan 1a permits it, so no route handler is needed. - await pbBrowser - .collection("cards") - .update(card.id, { column: destinationColumnId, position }); - } catch (error) { - console.error("[board] move failed", error); + // Through the server route, not the browser SDK: the auth token lives in + // an httpOnly cookie that JavaScript cannot read, so the browser-side + // PocketBase instance has no credentials. See Task 10. + const response = await fetch(`/api/owner/cards/${card.id}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: destinationColumnId, position }), + }); + + if (!response.ok) { + console.error("[board] move failed", response.status); setCards(previous); } } diff --git a/src/lib/activity/diff.test.ts b/src/lib/activity/diff.test.ts new file mode 100644 index 0000000..aa8e8d2 --- /dev/null +++ b/src/lib/activity/diff.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { buildActivityRows } from "@/lib/activity/diff"; +import type { CardRecord } from "@/lib/pb/types"; + +const card = { + id: "card_1", + project: "proj_1", + priority: "medium", + target_date: "2026-10-01", +} as CardRecord; + +describe("buildActivityRows", () => { + it("emits one row per changed field", () => { + const rows = buildActivityRows(card, { priority: "high" }, "u_wf"); + expect(rows).toEqual([ + { + project: "proj_1", + card: "card_1", + actor: "u_wf", + action: "priority_changed", + field: "priority", + old_value: "medium", + new_value: "high", + }, + ]); + }); + + it("emits nothing when the value is unchanged", () => { + expect(buildActivityRows(card, { priority: "medium" }, "u_wf")).toEqual([]); + }); + + it("emits nothing for an empty patch", () => { + expect(buildActivityRows(card, {}, "u_wf")).toEqual([]); + }); + + it("uses date_changed for target_date", () => { + const rows = buildActivityRows(card, { target_date: "2026-11-15" }, "u_wf"); + expect(rows[0].action).toBe("date_changed"); + expect(rows[0].old_value).toBe("2026-10-01"); + expect(rows[0].new_value).toBe("2026-11-15"); + }); + + it("records clearing a date as an empty new_value", () => { + const rows = buildActivityRows(card, { target_date: "" }, "u_wf"); + expect(rows[0].new_value).toBe(""); + expect(rows[0].old_value).toBe("2026-10-01"); + }); + + it("emits one row per field when both change", () => { + const rows = buildActivityRows( + card, + { priority: "low", target_date: "2026-12-01" }, + "u_wf" + ); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.field).sort()).toEqual(["priority", "target_date"]); + }); + + it("normalises a PocketBase datetime to a plain date before comparing", () => { + // PocketBase returns date fields as "2026-10-01 00:00:00.000Z". Comparing + // that raw against "2026-10-01" would log a change on every save. + const pbCard = { + ...card, + target_date: "2026-10-01 00:00:00.000Z", + } as CardRecord; + expect(buildActivityRows(pbCard, { target_date: "2026-10-01" }, "u_wf")).toEqual( + [] + ); + }); +}); \ No newline at end of file diff --git a/src/lib/activity/diff.ts b/src/lib/activity/diff.ts new file mode 100644 index 0000000..1b12148 --- /dev/null +++ b/src/lib/activity/diff.ts @@ -0,0 +1,63 @@ +/** + * Turn a card patch into audit rows. + * + * Only genuine changes produce rows. A client saving a form without altering + * anything must not generate log noise, or the log stops being readable and + * therefore stops being used. + */ + +import type { ActivityAction, CardRecord } from "@/lib/pb/types"; +import type { ClientCardPatch } from "@/lib/cards/client-patch"; + +export interface ActivityRowInput { + project: string; + card: string; + actor: string; + action: ActivityAction; + field: string; + old_value: string; + new_value: string; +} + +const ACTION_FOR_FIELD: Record = { + priority: "priority_changed", + target_date: "date_changed", +}; + +/** + * PocketBase returns a date field as "2026-10-01 00:00:00.000Z" but accepts + * "2026-10-01". Compare on the date part alone, or every save looks like an + * edit. + */ +function normalise(value: string): string { + if (!value) return ""; + return value.slice(0, 10); +} + +export function buildActivityRows( + card: CardRecord, + patch: ClientCardPatch, + actorId: string +): ActivityRowInput[] { + const rows: ActivityRowInput[] = []; + + for (const key of Object.keys(patch) as Array) { + const next = patch[key]; + if (next === undefined) continue; + + const current = card[key] ?? ""; + if (normalise(String(current)) === normalise(String(next))) continue; + + rows.push({ + project: card.project, + card: card.id, + actor: actorId, + action: ACTION_FOR_FIELD[key], + field: key, + old_value: String(current), + new_value: String(next), + }); + } + + return rows; +} \ No newline at end of file