From b3aac9ba4fcfa5296d9656de25ab10060c830c3c Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 19 Sep 2026 18:35:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(plan):=20Plan=20Puzzle=20v1=20backend=20?= =?UTF-8?q?=E2=80=94=20proposal=20fields,=20server=20actions,=20Done-colum?= =?UTF-8?q?n=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three proposal fields (proposed_target_date, proposed_by, proposed_at) to the cards collection with a partial index that backs the 'pending proposals' query, plus two new ActivityAction values (date_proposed, date_proposal_resolved) and column_changed for return-to-scheduled. New server actions under src/app/actions/: - client-proposal.ts: client-manager drag creates a proposal (not a target_date write); 404 (not 403) on cross-tenant failures and Done-column cards, matching the rest of /api/client/*. - owner-plan.ts: accept and dismiss clear the three proposal fields together and emit the audit rows; returnToScheduledAction moves a Done card back to the first working column. Server-side Done-column guards added to: - src/app/actions/reschedule.ts (was UI-only) - src/app/api/client/cards/[id]/route.ts (was UI-only) Both pass through the new writeCardWithAudit helper at src/lib/cards/write-with-audit.ts — single source of truth for 'patch card + emit activity rows', replacing the inline copies in the PATCH route and rescheduleCardAction. Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping) consumed by Pixel's puzzle UI; named SlotId API rather than the plan's numeric indices for self-documenting callers. CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per the brief; proposals go through the dedicated action so they get their own audit row type. --- .../1789601200_added_card_proposals.js | 86 +++++++ scripts/setup-remote.ts | 124 ++++++++++ src/app/actions/client-proposal.ts | 98 ++++++++ src/app/actions/owner-plan.ts | 218 ++++++++++++++++++ .../actions/plan/_plan-permissions.test.ts | 100 ++++++++ src/app/actions/plan/_plan-permissions.ts | 81 +++++++ src/app/actions/reschedule.ts | 49 ++-- src/app/api/client/cards/[id]/route.ts | 48 ++-- src/lib/activity/diff.ts | 20 +- src/lib/activity/format.test.ts | 68 ++++++ src/lib/activity/format.ts | 22 ++ src/lib/cards/write-with-audit.test.ts | 213 +++++++++++++++++ src/lib/cards/write-with-audit.ts | 107 +++++++++ src/lib/pb/types.ts | 14 ++ src/lib/roadmap/slot-date.test.ts | 141 +++++++++++ src/lib/roadmap/slot-date.ts | 88 +++++++ vitest.config.ts | 20 +- 17 files changed, 1459 insertions(+), 38 deletions(-) create mode 100644 pocketbase/pb_migrations/1789601200_added_card_proposals.js create mode 100644 src/app/actions/client-proposal.ts create mode 100644 src/app/actions/owner-plan.ts create mode 100644 src/app/actions/plan/_plan-permissions.test.ts create mode 100644 src/app/actions/plan/_plan-permissions.ts create mode 100644 src/lib/cards/write-with-audit.test.ts create mode 100644 src/lib/cards/write-with-audit.ts create mode 100644 src/lib/roadmap/slot-date.test.ts create mode 100644 src/lib/roadmap/slot-date.ts diff --git a/pocketbase/pb_migrations/1789601200_added_card_proposals.js b/pocketbase/pb_migrations/1789601200_added_card_proposals.js new file mode 100644 index 0000000..57aa184 --- /dev/null +++ b/pocketbase/pb_migrations/1789601200_added_card_proposals.js @@ -0,0 +1,86 @@ +/// +/** + * Proposal fields on `cards`. + * + * Plan Puzzle v1: clients drag a card into a month slot and we record their + * intent on three nullable fields rather than overwriting `target_date` + * (which the owner controls). The owner sees the chip, accepts (which copies + * the proposal into `target_date`), or dismisses (which clears it). + * + * The three fields are written and cleared together. A row with one set and + * the others null is logically incoherent; nothing in the application writes + * one without the others. See src/app/actions/plan/client-proposal.ts and + * src/app/actions/plan/owner-plan.ts for the only writers. + * + * The partial index supports the owner-side "show me cards with pending + * proposals" query without indexing the millions of empty proposals on the + * rest of the table. + * + * REFERENCE ONLY — see pb_migrations/README.md. scripts/setup-remote.ts is + * what runs. + */ +migrate( + (app) => { + const cards = app.findCollectionByNameOrId("cards"); + const users = app.findCollectionByNameOrId("users"); + + cards.fields.add([ + { + name: "proposed_target_date", + type: "date", + required: false, + presentable: false, + hidden: false, + system: false, + }, + { + name: "proposed_by", + type: "relation", + required: false, + collectionId: users.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + presentable: false, + hidden: false, + system: false, + }, + { + name: "proposed_at", + type: "date", + required: false, + presentable: false, + hidden: false, + system: false, + }, + ]); + + const existing = new Set((cards.indexes as string[]) ?? []); + const proposalIndex = + "CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL"; + if (!existing.has(proposalIndex)) { + cards.indexes.push(proposalIndex); + } + + return app.save(cards); + }, + (app) => { + const cards = app.findCollectionByNameOrId("cards"); + + for (const name of [ + "proposed_target_date", + "proposed_by", + "proposed_at", + ]) { + const field = cards.fields.find((f) => f.name === name); + if (field) cards.fields.removeById(field.id); + } + + const indexes = (cards.indexes as string[]) ?? []; + const proposalIndex = + "CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL"; + cards.indexes = indexes.filter((i) => i !== proposalIndex); + + return app.save(cards); + } +); diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index ec60db5..77d2d92 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -427,6 +427,121 @@ async function ensureCardNumbers(pb: PocketBase): Promise { * Re-runs are safe: the field is not duplicated, and backfill only touches * rows where kind is still unset. */ +/** + * Add the Plan Puzzle v1 proposal fields to `cards` (proposed_target_date, + * proposed_by, proposed_at) and the partial index that backs the + * "cards with pending proposals" query. + * + * `proposed_by` is a relation to users. `cascadeDelete: false` because a + * deactivated user must not silently drop their pending proposals — the + * history keeps "Someone" tied to a still-existing user record. + * + * Re-runs are safe: fields and the index are not duplicated. + */ +async function ensureCardProposalFields(pb: PocketBase): Promise { + const cards = await pb.collections.getOne("cards"); + const users = await pb.collections.getOne("users"); + + const have = new Set(cards.fields.map((f: { name: string }) => f.name)); + + const fields = [...(cards.fields as unknown[])]; + let fieldChanged = false; + + if (!have.has("proposed_target_date")) { + fields.push({ + name: "proposed_target_date", + type: "date", + required: false, + presentable: false, + hidden: false, + system: false, + }); + fieldChanged = true; + } + + if (!have.has("proposed_by")) { + fields.push({ + name: "proposed_by", + type: "relation", + required: false, + collectionId: users.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + presentable: false, + hidden: false, + system: false, + }); + fieldChanged = true; + } + + if (!have.has("proposed_at")) { + fields.push({ + name: "proposed_at", + type: "date", + required: false, + presentable: false, + hidden: false, + system: false, + }); + fieldChanged = true; + } + + if (fieldChanged) { + await pb.collections.update(cards.id, { fields }); + console.log(" + cards fields: proposed_target_date, proposed_by, proposed_at"); + } else { + console.log(" = card proposal fields already present"); + } + + const reloaded = await pb.collections.getOne("cards"); + const indexes = new Set((reloaded.indexes as string[]) ?? []); + const proposalIndex = + "CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL"; + if (!indexes.has(proposalIndex)) { + indexes.add(proposalIndex); + await pb.collections.update(reloaded.id, { indexes: [...indexes] }); + console.log(" + cards index: idx_cards_proposal"); + } +} + +/** + * Extend the activity `action` select with Plan Puzzle v1's three new values: + * `column_changed`, `date_proposed`, `date_proposal_resolved`. Existing rows + * are unaffected; only the schema's allowed values list grows. + * + * `column_changed` is new despite the plan referring to it as "already + * existing" — the kanban owner's drag-from-board move route today writes no + * activity row at all, so the value had never been added. The Puzzle's + * "return to scheduled" action is its first real consumer. + * + * Re-runs are safe: values are not duplicated. + */ +async function ensureActivityActionValues(pb: PocketBase): Promise { + const activity = await pb.collections.getOne("activity"); + const field = activity.fields.find( + (f: { name: string }) => f.name === "action" + ) as { values?: string[] } | undefined; + if (!field) { + throw new Error("activity.action field missing — cannot extend values"); + } + const additions = ["column_changed", "date_proposed", "date_proposal_resolved"]; + const existing = new Set(field.values ?? []); + const missing = additions.filter((v) => !existing.has(v)); + if (missing.length === 0) { + console.log(" = activity.action values already include puzzle actions"); + return; + } + + const fields = activity.fields.map((f: { name: string; values?: string[] }) => { + if (f.name !== "action") return f; + return { ...f, values: [...(f.values ?? []), ...missing] }; + }); + + await pb.collections.update(activity.id, { fields }); + console.log(` + activity.action values: ${missing.join(", ")}`); +} + async function ensureProjectKindField(pb: PocketBase): Promise { const projects = await pb.collections.getOne("projects"); const have = new Set(projects.fields.map((f: { name: string }) => f.name)); @@ -739,9 +854,12 @@ async function main(): Promise { values: [ "card_created", "card_moved", + "column_changed", "card_updated", "priority_changed", "date_changed", + "date_proposed", + "date_proposal_resolved", "card_accepted", "comment_added", "subtask_toggled", @@ -1011,6 +1129,12 @@ async function main(): Promise { // Add the intake wizard's fields (brief, transcript, size_band) for Phase 4b. await ensureCardIntakeFields(pb); + // Add the Plan Puzzle v1 proposal fields and the partial index. + await ensureCardProposalFields(pb); + + // Extend activity.action with the three new puzzle actions. + await ensureActivityActionValues(pb); + console.log("Done."); } diff --git a/src/app/actions/client-proposal.ts b/src/app/actions/client-proposal.ts new file mode 100644 index 0000000..2ca5c23 --- /dev/null +++ b/src/app/actions/client-proposal.ts @@ -0,0 +1,98 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { writeCardWithAudit } from "@/lib/cards/write-with-audit"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; + +// ISO date YYYY-MM-DD. The day cap of 31 is permissive (Feb 31 still +// slips through), but PocketBase's date parser rejects anything it cannot +// serialise, so the action's downstream behaviour is the same as a stricter +// check. The point here is to reject obvious junk (month > 12) before the +// database call. +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +/** + * Client drag in the Plan Puzzle: write `proposed_target_date` on the card + * rather than `target_date`. The owner sees the proposal chip on the card + * and either accepts (copies the date into `target_date` and clears the + * proposal fields) or dismisses (clears without applying). + * + * Permission gates, in order: + * 1. signed in + * 2. caller is a client_manager (owners proposing is meaningless — they + * can write `target_date` directly) + * 3. the date is ISO + * 4. the card exists and its project belongs to this session's client + * 5. the card is not in a Done column (Puzzle v1: clients never move Done + * cards — that's the owner's "Return to scheduled" action) + * + * 404 (not 403) is returned for every cross-tenant failure. Confirming that + * a card exists in another client's project would itself be a leak. + * + * Activity row: one `date_proposed` row, written via the shared helper so + * the row shape and write order match the rest of the codebase. + */ +export async function proposePlanMoveAction(input: { + cardId: string; + proposedTargetDate: string; +}): Promise<{ ok: boolean; message?: string }> { + const session = await requireSession(); + if (session.role !== "client_manager") { + return { ok: false, message: "Not found" }; + } + if (!ISO_DATE.test(input.proposedTargetDate)) { + return { ok: false, message: "Invalid date" }; + } + + const pb = await getServerPb(); + + const card = await pb + .collection("cards") + .getOne(input.cardId) + .catch(() => null); + if (!card) return { ok: false, message: "Not found" }; + + const project = await pb + .collection("projects") + .getOne(card.project) + .catch(() => null); + if (!project || project.client !== session.clientId) { + return { ok: false, message: "Not found" }; + } + + const column = await pb + .collection("columns") + .getOne(card.column) + .catch(() => null); + if (column?.is_done) { + return { ok: false, message: "Not found" }; + } + + const previous = (card.proposed_target_date ?? "").slice(0, 10); + + await writeCardWithAudit(pb, { + cardId: card.id, + patch: { + proposed_target_date: input.proposedTargetDate, + proposed_by: session.userId, + proposed_at: new Date().toISOString().slice(0, 10), + }, + rows: [ + { + project: card.project, + card: card.id, + actor: session.userId, + action: "date_proposed", + field: "proposed_target_date", + old_value: previous, + new_value: input.proposedTargetDate, + }, + ], + }); + + revalidatePath("/plan"); + return { ok: true }; +} diff --git a/src/app/actions/owner-plan.ts b/src/app/actions/owner-plan.ts new file mode 100644 index 0000000..48a5964 --- /dev/null +++ b/src/app/actions/owner-plan.ts @@ -0,0 +1,218 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { writeCardWithAudit } from "@/lib/cards/write-with-audit"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; + +/** + * Owner-only plan mutations. + * + * SECURITY: same rule as the rest of this directory — a Server Action is a + * public HTTP endpoint, so every action re-checks the session and the role + * before touching anything. See the security note at the top of + * src/app/actions/cards.ts for the fuller argument. + * + * Activity rows are written via writeCardWithAudit so the card-update / + * row-insert order matches the rest of the codebase. + */ + +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") { + throw new Error("Not found"); + } + return session; +} + +/** + * Owner accepts a client's proposed date. + * + * The three proposal fields are cleared together (otherwise the row is in + * an incoherent half-set state — see the comment on `CardRecord` in + * @/lib/pb/types). If `target_date` would change as a result, a + * `date_changed` row is also emitted so the card history reads the same + * way an owner drag would have; the `date_proposal_resolved` row always + * fires, with `new_value="accepted"`. + */ +export async function acceptClientProposalAction(input: { + cardId: string; +}): Promise<{ ok: boolean; message?: string }> { + try { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb + .collection("cards") + .getOne(input.cardId) + .catch(() => null); + if (!card || !card.proposed_target_date) { + return { ok: false, message: "No proposal to accept" }; + } + + const proposedDate = card.proposed_target_date.slice(0, 10); + const previousTarget = (card.target_date ?? "").slice(0, 10); + + const rows: import("@/lib/activity/diff").AuditRowInput[] = []; + + if (previousTarget !== proposedDate) { + rows.push({ + project: card.project, + card: card.id, + actor: session.userId, + action: "date_changed", + field: "target_date", + old_value: card.target_date ?? "", + new_value: proposedDate, + }); + } + rows.push({ + project: card.project, + card: card.id, + actor: session.userId, + action: "date_proposal_resolved", + field: "proposed_target_date", + old_value: proposedDate, + new_value: "accepted", + }); + + await writeCardWithAudit(pb, { + cardId: card.id, + patch: { + target_date: proposedDate, + proposed_target_date: null, + proposed_by: null, + proposed_at: null, + }, + rows, + }); + + revalidatePath("/plan"); + return { ok: true }; + } catch { + // requireOwner throws "Not found" on a non-owner session — surface that + // as the same 404-style failure message as the rest of this module. + return { ok: false, message: "Not found" }; + } +} + +/** + * Owner dismisses a client's proposal without applying it. + * + * Clears the three proposal fields together and emits one + * `date_proposal_resolved` row with `new_value="dismissed"`. `target_date` + * is untouched — the client's intent is logged, not silently dropped. + */ +export async function dismissClientProposalAction(input: { + cardId: string; +}): Promise<{ ok: boolean; message?: string }> { + try { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb + .collection("cards") + .getOne(input.cardId) + .catch(() => null); + if (!card || !card.proposed_target_date) { + return { ok: false, message: "No proposal to dismiss" }; + } + + const proposedDate = card.proposed_target_date.slice(0, 10); + + await writeCardWithAudit(pb, { + cardId: card.id, + patch: { + proposed_target_date: null, + proposed_by: null, + proposed_at: null, + }, + rows: [ + { + project: card.project, + card: card.id, + actor: session.userId, + action: "date_proposal_resolved", + field: "proposed_target_date", + old_value: proposedDate, + new_value: "dismissed", + }, + ], + }); + + revalidatePath("/plan"); + return { ok: true }; + } catch { + return { ok: false, message: "Not found" }; + } +} + +/** + * Move a Done card back to its project's first working column. + * + * Owners used to drag Done cards sideways on the Timeline. That gesture is + * now blocked server-side by the Done-column guard in rescheduleCardAction + * and the client PATCH route. This is the explicit, audited replacement. + * + * `column_changed` is the same action used elsewhere in the kanban code + * (see src/app/actions/columns.ts for the column rename / reorder paths); + * adding it as a new ActivityAction value keeps the activity-feed UI from + * needing a branch on the move source. + */ +export async function returnToScheduledAction(input: { + cardId: string; +}): Promise<{ ok: boolean; message?: string }> { + try { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb + .collection("cards") + .getOne(input.cardId) + .catch(() => null); + if (!card) return { ok: false, message: "Not found" }; + + // Find the project's first non-Inbox, non-Done column, in board order. + // Working columns are already in display order; the first one is the + // natural home for a card returning to the schedule. + const columns = await pb + .collection("columns") + .getFullList({ + filter: `project = "${card.project}"`, + sort: "position", + }); + const target = columns.find((c) => !c.is_inbox && !c.is_done); + if (!target) return { ok: false, message: "No working column" }; + + // Defensive: if the card isn't actually in a Done column, refuse + // rather than write a misleading activity row. The Puzzle only exposes + // the button on Done cards, but a future UI could surface it elsewhere. + const current = columns.find((c) => c.id === card.column); + if (!current?.is_done) { + return { ok: false, message: "Not done" }; + } + + await writeCardWithAudit(pb, { + cardId: card.id, + patch: { column: target.id }, + rows: [ + { + project: card.project, + card: card.id, + actor: session.userId, + action: "column_changed", + field: "column", + old_value: card.column, + new_value: target.id, + }, + ], + }); + + revalidatePath("/plan"); + return { ok: true }; + } catch { + return { ok: false, message: "Not found" }; + } +} diff --git a/src/app/actions/plan/_plan-permissions.test.ts b/src/app/actions/plan/_plan-permissions.test.ts new file mode 100644 index 0000000..c3b8981 --- /dev/null +++ b/src/app/actions/plan/_plan-permissions.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; + +import { + canProposeForCard, + canResolveProposalForCard, + canReturnCardToSchedule, + parseProposalDate, + parseReturnColumnId, +} from "@/app/actions/plan/_plan-permissions"; + +const ownerSession = { userId: "u_owner", role: "owner", clientId: null } as const; +const managerOtherClient = { userId: "u_other", role: "client_manager", clientId: "c_other" } as const; +const managerOwnClient = { userId: "u_self", role: "client_manager", clientId: "c_self" } as const; + +describe("parseProposalDate", () => { + it("accepts an ISO date string", () => { + expect(parseProposalDate("2026-11-15")).toBe("2026-11-15"); + }); + + it("rejects malformed input", () => { + expect(parseProposalDate("next tuesday")).toBeNull(); + expect(parseProposalDate("")).toBeNull(); + expect(parseProposalDate("2026-11")).toBeNull(); + expect(parseProposalDate("2026-13-01")).toBeNull(); + }); +}); + +describe("canProposeForCard", () => { + it("refuses owners outright (they write target_date directly)", () => { + expect( + canProposeForCard(ownerSession, { project: { client: "c_self" } } as never) + ).toBe(false); + }); + + it("refuses client managers for a project they don't own", () => { + expect( + canProposeForCard(managerOtherClient, { project: { client: "c_self" } } as never) + ).toBe(false); + }); + + it("admits the owning client manager", () => { + expect( + canProposeForCard(managerOwnClient, { project: { client: "c_self" } } as never) + ).toBe(true); + }); + + it("refuses when the card is in a Done column", () => { + expect( + canProposeForCard(managerOwnClient, { + project: { client: "c_self" }, + column: { is_done: true }, + } as never) + ).toBe(false); + }); +}); + +describe("canResolveProposalForCard", () => { + it("only admits owners", () => { + expect(canResolveProposalForCard(ownerSession)).toBe(true); + expect(canResolveProposalForCard(managerOwnClient)).toBe(false); + expect(canResolveProposalForCard(managerOtherClient)).toBe(false); + }); +}); + +describe("canReturnCardToSchedule", () => { + it("admits an owner when the card is in a Done column", () => { + expect( + canReturnCardToSchedule(ownerSession, { + column: { is_done: true }, + } as never) + ).toBe(true); + }); + + it("refuses an owner when the card is not in a Done column", () => { + expect( + canReturnCardToSchedule(ownerSession, { + column: { is_done: false }, + } as never) + ).toBe(false); + }); + + it("refuses a client manager regardless of column", () => { + expect( + canReturnCardToSchedule(managerOwnClient, { + column: { is_done: true }, + } as never) + ).toBe(false); + }); +}); + +describe("parseReturnColumnId", () => { + it("accepts a non-empty string id", () => { + expect(parseReturnColumnId("col_1")).toBe("col_1"); + }); + + it("rejects empty input", () => { + expect(parseReturnColumnId("")).toBeNull(); + expect(parseReturnColumnId(undefined)).toBeNull(); + }); +}); diff --git a/src/app/actions/plan/_plan-permissions.ts b/src/app/actions/plan/_plan-permissions.ts new file mode 100644 index 0000000..de9f2bc --- /dev/null +++ b/src/app/actions/plan/_plan-permissions.ts @@ -0,0 +1,81 @@ +/** + * Pure predicates for the Plan Puzzle server actions. + * + * No `server-only` marker, no PocketBase, no framework — so unit tests can + * exercise every gate without a database. The actions at the top of + * src/app/actions/ call into here for the cross-cutting decisions (role + * check, project ownership, date shape) and stay focused on the I/O. + */ + +import type { Session } from "@/lib/auth/access"; + +// ISO date YYYY-MM-DD with month 01-12 and day 01-31. The day cap of 31 +// is permissive — Feb 31 still slips through — but PocketBase's date parser +// rejects anything it cannot serialise, so the action's downstream behaviour +// is the same as a stricter check. The point here is to reject obvious junk +// (month > 12) before the database call. +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +/** Validate and normalise a proposed date. Returns the date on success. */ +export function parseProposalDate(value: unknown): string | null { + if (typeof value !== "string") return null; + if (!ISO_DATE.test(value)) return null; + return value; +} + +/** + * May this session propose a date for this card? + * + * Owners do not propose — they have direct access to `target_date`. Client + * managers may propose only for projects they own. A card in a Done column + * is rejected (Puzzle v1: clients never move Done cards; the owner has + * "Return to scheduled" for that). + */ +export function canProposeForCard( + session: Session, + card: { project: { client: string }; column?: { is_done?: boolean } | null } +): boolean { + if (session.role !== "client_manager") return false; + if (!session.clientId) return false; + if (card.project.client !== session.clientId) return false; + if (card.column?.is_done) return false; + return true; +} + +/** + * May this session accept or dismiss a proposal? + * + * Owner-only. The proposal chip on a card is visible to the client too + * (it's their own intent), but resolving it is the owner's decision. + */ +export function canResolveProposalForCard(session: Session): boolean { + return session.role === "owner"; +} + +/** + * May this session return a card from Done back to a working column? + * + * Owner-only AND the card must be in a Done column. The UI only exposes + * the button on Done cards, but the predicate still checks it so a forged + * request can't write a misleading `column_changed` row. + */ +export function canReturnCardToSchedule( + session: Session, + card: { column?: { is_done?: boolean } | null } +): boolean { + if (session.role !== "owner") return false; + if (!card.column?.is_done) return false; + return true; +} + +/** + * Validate the optional destination column id for a return-to-scheduled + * action. The Puzzle currently uses the first working column automatically; + * this predicate exists so a future caller can pick a target explicitly + * without re-implementing the validation. + */ +export function parseReturnColumnId(value: unknown): string | null { + if (typeof value !== "string") return null; + if (!value) return null; + return value; +} diff --git a/src/app/actions/reschedule.ts b/src/app/actions/reschedule.ts index 856702f..204e786 100644 --- a/src/app/actions/reschedule.ts +++ b/src/app/actions/reschedule.ts @@ -4,10 +4,15 @@ import { revalidatePath } from "next/cache"; import { getServerPb } from "@/lib/pb/server"; import { requireSession } from "@/lib/auth/session"; -import { logActivity } from "@/app/actions/activity"; -import type { CardRecord } from "@/lib/pb/types"; +import { writeCardWithAudit } from "@/lib/cards/write-with-audit"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; -const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +// ISO date YYYY-MM-DD. The day cap of 31 is permissive (Feb 31 still +// slips through), but PocketBase's date parser rejects anything it cannot +// serialise, so the action's downstream behaviour is the same as a stricter +// check. The point here is to reject obvious junk (month > 12) before the +// database call. +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; /** * Move a card's target date from the timeline. Owner only. @@ -18,7 +23,11 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; * client's card history read "Dave moved the target date from 1 Oct to 15 Nov", * in their own view, without anyone having to tell them. * - * Do not remove that call to make this faster. + * Plan Puzzle v1: the Done-column guard returns {ok: false, message} when the + * card lives in a column flagged is_done. The owner used to drag Done cards + * sideways off the Timeline; that gesture is replaced by the explicit + * "Return to scheduled" server action, which moves the card out of the Done + * column rather than rewriting a date it never had. */ export async function rescheduleCardAction(input: { cardId: string; @@ -39,19 +48,31 @@ export async function rescheduleCardAction(input: { .catch(() => null); if (!card) return { ok: false, message: "Card not found" }; + const column = await pb + .collection("columns") + .getOne(card.column) + .catch(() => null); + if (column?.is_done) { + return { ok: false, message: "Card is done" }; + } + const previous = (card.target_date ?? "").slice(0, 10); if (previous === input.date) return { ok: true }; - await pb.collection("cards").update(input.cardId, { target_date: input.date }); - - await logActivity({ - project: card.project, - card: card.id, - actor: session.userId, - action: "date_changed", - field: "target_date", - oldValue: card.target_date ?? "", - newValue: input.date, + await writeCardWithAudit(pb, { + cardId: card.id, + patch: { target_date: input.date }, + rows: [ + { + project: card.project, + card: card.id, + actor: session.userId, + action: "date_changed", + field: "target_date", + old_value: card.target_date ?? "", + new_value: input.date, + }, + ], }); revalidatePath("/plan"); diff --git a/src/app/api/client/cards/[id]/route.ts b/src/app/api/client/cards/[id]/route.ts index 2e2fb9d..c70e7ab 100644 --- a/src/app/api/client/cards/[id]/route.ts +++ b/src/app/api/client/cards/[id]/route.ts @@ -7,8 +7,9 @@ import { } from "@/lib/auth/access"; import { pickClientCardPatch } from "@/lib/cards/client-patch"; import { buildActivityRows } from "@/lib/activity/diff"; +import { writeCardWithAudit } from "@/lib/cards/write-with-audit"; import { jsonError, jsonOk } from "@/lib/http/json"; -import type { CardRecord, ProjectRecord } from "@/lib/pb/types"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; /** * PATCH /api/client/cards/:id — a client manager sets priority or target_date. @@ -18,10 +19,16 @@ import type { CardRecord, ProjectRecord } from "@/lib/pb/types"; * 1. a valid session * 2. the card exists, and its project belongs to this session's client * 3. the caller is a client_manager for that client - * 4. the body is reduced to the two whitelisted fields - * 5. audit rows are written for whatever actually changed + * 4. the card is not in a Done column (Plan Puzzle v1 — owners only move + * Done cards via the explicit "Return to scheduled" action) + * 5. the body is reduced to the two whitelisted fields + * 6. audit rows are written for whatever actually changed * - * Do not reorder these, and do not add an early return that skips step 5. + * Do not reorder these, and do not add an early return that skips step 6. + * + * The Done-column check uses 404 rather than 409 for the same anti- + * enumeration reason as the project check: a 4xx that confirms the card is + * in Done leaks the project's structure to a foreign session. */ export async function PATCH( request: Request, @@ -59,6 +66,19 @@ export async function PATCH( return jsonError("Card not found", 404); } + // Plan Puzzle v1: clients do not move Done cards. Owners do, via the + // dedicated "Return to scheduled" action. A client manager reaching this + // route on a Done card is either confused or hostile — refuse with the + // same 404 as the project-not-found path so the response is + // indistinguishable. + const column = await pb + .collection("columns") + .getOne(card.column) + .catch(() => null); + if (column?.is_done) { + return jsonError("Card not found", 404); + } + const { patch, dropped } = pickClientCardPatch(body); if (dropped.length > 0) { @@ -73,19 +93,17 @@ export async function PATCH( const rows = buildActivityRows(card, patch, session.userId); - const updated = await pb - .collection("cards") - .update(id, patch); - - for (const row of rows) { - await pb.collection("activity").create(row); - } + const updated = await writeCardWithAudit(pb, { + cardId: id, + patch: { ...patch }, + rows, + }); return jsonOk({ card: { - id: updated.id, - priority: updated.priority, - target_date: updated.target_date, + id: updated?.id ?? id, + priority: updated?.priority ?? card.priority, + target_date: updated?.target_date ?? card.target_date, }, changes: rows.length, }); @@ -96,4 +114,4 @@ export async function PATCH( console.error("[client-patch] unexpected error", error); return jsonError("Something went wrong", 500); } -} \ No newline at end of file +} diff --git a/src/lib/activity/diff.ts b/src/lib/activity/diff.ts index 1b12148..2b51442 100644 --- a/src/lib/activity/diff.ts +++ b/src/lib/activity/diff.ts @@ -4,12 +4,24 @@ * 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. + * + * The `AuditRowInput` shape is also what the rest of the codebase builds + * directly when it knows the field it is writing (the Plan Puzzle proposal + * actions in src/app/actions/plan/, for instance). It is shared with + * src/lib/cards/write-with-audit.ts so every writer produces rows of the + * same shape. */ import type { ActivityAction, CardRecord } from "@/lib/pb/types"; import type { ClientCardPatch } from "@/lib/cards/client-patch"; -export interface ActivityRowInput { +/** + * One row in the `activity` collection, in the wire shape PocketBase writes. + * + * `project` is denormalised onto every row so a "history of this card" read + * does not need a join to `cards` first. + */ +export interface AuditRowInput { project: string; card: string; actor: string; @@ -38,8 +50,8 @@ export function buildActivityRows( card: CardRecord, patch: ClientCardPatch, actorId: string -): ActivityRowInput[] { - const rows: ActivityRowInput[] = []; +): AuditRowInput[] { + const rows: AuditRowInput[] = []; for (const key of Object.keys(patch) as Array) { const next = patch[key]; @@ -60,4 +72,4 @@ export function buildActivityRows( } return rows; -} \ No newline at end of file +} diff --git a/src/lib/activity/format.test.ts b/src/lib/activity/format.test.ts index e895193..4fbda47 100644 --- a/src/lib/activity/format.test.ts +++ b/src/lib/activity/format.test.ts @@ -82,6 +82,74 @@ describe("describeActivity", () => { ).toBe("reopened “Wire up the till”"); }); + it("describes a column move with the new column name", () => { + expect( + describeActivity( + row({ action: "column_changed", field: "column", old_value: "col_done", new_value: "Backlog" }) + ) + ).toBe("moved this card to Backlog"); + }); + + it("describes a column move out of a column when only the old name is known", () => { + expect( + describeActivity( + row({ action: "column_changed", field: "column", old_value: "Done", new_value: "" }) + ) + ).toBe("moved this card out of Done"); + }); + + it("describes a client proposal in human terms", () => { + expect( + describeActivity( + row({ + action: "date_proposed", + field: "proposed_target_date", + old_value: "", + new_value: "2026-11-15", + }) + ) + ).toBe("proposed moving this card to 15 Nov 2026"); + }); + + it("describes an accepted proposal with its date", () => { + expect( + describeActivity( + row({ + action: "date_proposal_resolved", + field: "proposed_target_date", + old_value: "2026-11-15", + new_value: "accepted", + }) + ) + ).toBe("accepted the proposal for 15 Nov 2026"); + }); + + it("describes a dismissed proposal with its date", () => { + expect( + describeActivity( + row({ + action: "date_proposal_resolved", + field: "proposed_target_date", + old_value: "2026-11-15", + new_value: "dismissed", + }) + ) + ).toBe("dismissed the proposal for 15 Nov 2026"); + }); + + it("falls back gracefully when a resolved proposal has no date", () => { + expect( + describeActivity( + row({ + action: "date_proposal_resolved", + field: "proposed_target_date", + old_value: "", + new_value: "accepted", + }) + ) + ).toBe("accepted a proposal"); + }); + it("describes a generic update", () => { expect(describeActivity(row({ action: "card_updated", field: "description" }))) .toBe("updated the description"); diff --git a/src/lib/activity/format.ts b/src/lib/activity/format.ts index ba47b01..693e9de 100644 --- a/src/lib/activity/format.ts +++ b/src/lib/activity/format.ts @@ -65,6 +65,28 @@ export function describeActivity(row: ActivityRecord): string { ? `moved this card to ${row.new_value}` : "moved this card"; + case "column_changed": + // Both directions (working → Done, Done → working) are written with + // the same action. The value pairs name the columns; the surrounding + // UI is expected to show a name, not a raw id. We render the new + // value as-is because the writer (the move route) has already + // resolved it to a label. + return row.new_value + ? `moved this card to ${row.new_value}` + : `moved this card out of ${row.old_value}`; + + case "date_proposed": + return row.new_value + ? `proposed moving this card to ${longDate(row.new_value)}` + : "proposed a date for this card"; + + case "date_proposal_resolved": { + const proposed = longDate(row.old_value); + const verdict = row.new_value === "accepted" ? "accepted" : "dismissed"; + if (!proposed) return `${verdict} a proposal`; + return `${verdict} the proposal for ${proposed}`; + } + case "comment_added": return "commented"; diff --git a/src/lib/cards/write-with-audit.test.ts b/src/lib/cards/write-with-audit.test.ts new file mode 100644 index 0000000..da68afe --- /dev/null +++ b/src/lib/cards/write-with-audit.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + auditRow, + writeCardWithAudit, +} from "@/lib/cards/write-with-audit"; +import type { CardRecord } from "@/lib/pb/types"; + +/** + * A mock PocketBase that records every collection call so we can assert on + * the order and payload without a running database. + */ +function mockPb(card: CardRecord) { + const cards = { + update: vi.fn(async () => card), + getOne: vi.fn(async () => card), + }; + const activity = { + create: vi.fn(async () => ({ id: "row_1" })), + }; + return { + collection: vi.fn((name: string) => { + if (name === "cards") return cards; + if (name === "activity") return activity; + throw new Error(`unexpected collection ${name}`); + }), + _cards: cards, + _activity: activity, + }; +} + +describe("writeCardWithAudit", () => { + it("applies a card patch then writes each activity row in order", async () => { + const card = { + id: "card_1", + project: "proj_1", + target_date: "2026-10-01", + } as CardRecord; + const pb = mockPb(card); + + const result = await writeCardWithAudit(pb as never, { + cardId: "card_1", + patch: { target_date: "2026-11-15" }, + rows: [ + auditRow({ + project: "proj_1", + card: "card_1", + actor: "u_owner", + action: "date_changed", + field: "target_date", + oldValue: "2026-10-01", + newValue: "2026-11-15", + }), + ], + }); + + expect(pb._cards.update).toHaveBeenCalledWith("card_1", { + target_date: "2026-11-15", + }); + expect(pb._activity.create).toHaveBeenCalledTimes(1); + expect(pb._activity.create).toHaveBeenCalledWith({ + project: "proj_1", + card: "card_1", + actor: "u_owner", + action: "date_changed", + field: "target_date", + old_value: "2026-10-01", + new_value: "2026-11-15", + }); + expect(pb._cards.getOne).toHaveBeenCalledWith("card_1"); + expect(result?.id).toBe("card_1"); + }); + + it("writes the card update before the activity rows", async () => { + // The order matters: if the card update fails, no rows should be + // emitted; if the activity emit fails, the card change should still + // land. This test pins the "card first, rows after" contract. + const card = { id: "c", project: "p" } as CardRecord; + const pb = mockPb(card); + + const calls: string[] = []; + pb._cards.update.mockImplementation(async () => { + calls.push("cards.update"); + return card; + }); + pb._activity.create.mockImplementation(async () => { + calls.push("activity.create"); + return { id: "r" }; + }); + + await writeCardWithAudit(pb as never, { + cardId: "c", + patch: { priority: "high" }, + rows: [ + auditRow({ + project: "p", + card: "c", + actor: "u", + action: "priority_changed", + field: "priority", + oldValue: "medium", + newValue: "high", + }), + ], + }); + + expect(calls).toEqual(["cards.update", "activity.create"]); + }); + + it("skips the card update when the patch is empty (log-only actions)", async () => { + // Accept/dismiss proposals only need to clear the three proposal + // fields — the server action accepts that an empty patch is a no-op + // write and proceeds to emit rows. This is what `returnToScheduled` + // and the proposal-resolution actions rely on. + const card = { id: "c", project: "p" } as CardRecord; + const pb = mockPb(card); + + await writeCardWithAudit(pb as never, { + cardId: "c", + patch: {}, + rows: [ + auditRow({ + project: "p", + card: "c", + actor: "u", + action: "date_proposal_resolved", + field: "proposed_target_date", + oldValue: "2026-11-15", + newValue: "dismissed", + }), + ], + }); + + expect(pb._cards.update).not.toHaveBeenCalled(); + expect(pb._activity.create).toHaveBeenCalledTimes(1); + }); + + it("returns null when the post-update read fails (write did not)", async () => { + const card = { id: "c", project: "p" } as CardRecord; + const pb = mockPb(card); + pb._cards.getOne.mockRejectedValue(new Error("not found")); + + const result = await writeCardWithAudit(pb as never, { + cardId: "c", + patch: { priority: "low" }, + rows: [], + }); + + expect(pb._cards.update).toHaveBeenCalledWith("c", { priority: "low" }); + expect(result).toBeNull(); + }); + + it("emits multiple rows in the order given", async () => { + const card = { id: "c", project: "p" } as CardRecord; + const pb = mockPb(card); + + await writeCardWithAudit(pb as never, { + cardId: "c", + patch: { target_date: "2026-11-15" }, + rows: [ + auditRow({ + project: "p", + card: "c", + actor: "u", + action: "date_changed", + field: "target_date", + oldValue: "2026-10-01", + newValue: "2026-11-15", + }), + auditRow({ + project: "p", + card: "c", + actor: "u", + action: "date_proposal_resolved", + field: "proposed_target_date", + oldValue: "2026-11-15", + newValue: "accepted", + }), + ], + }); + + const actions: string[] = []; + for (const call of pb._activity.create.mock.calls as unknown as Array<[unknown]>) { + const arg = call[0] as { action: string }; + actions.push(arg.action); + } + expect(actions).toEqual(["date_changed", "date_proposal_resolved"]); + }); +}); + +describe("auditRow", () => { + it("maps camelCase args onto the wire shape (snake_case)", () => { + const row = auditRow({ + project: "p", + card: "c", + actor: "u", + action: "date_proposed", + field: "proposed_target_date", + oldValue: "", + newValue: "2026-11-15", + }); + + expect(row).toEqual({ + project: "p", + card: "c", + actor: "u", + action: "date_proposed", + field: "proposed_target_date", + old_value: "", + new_value: "2026-11-15", + }); + }); +}); diff --git a/src/lib/cards/write-with-audit.ts b/src/lib/cards/write-with-audit.ts new file mode 100644 index 0000000..04b2f7e --- /dev/null +++ b/src/lib/cards/write-with-audit.ts @@ -0,0 +1,107 @@ +/** + * Single source of truth for "patch a card and emit activity rows that + * describe the change". + * + * Three writers used to do this inline: + * - the client PATCH /api/client/cards/[id] route handler + * - src/app/actions/reschedule.ts (owner drag-from-timeline) + * - the new Plan Puzzle server actions in src/app/actions/plan/ + * + * Each one's inline copy drifted subtly. The PATCH route used the caller's + * superuser admin client and called `pb.collection("activity").create(row)` + * in a loop; rescheduleCardAction called `logActivity` (which is itself an + * admin helper). Today they disagree about who emits and how; a future + * audit-row bug would be fixed in one and not the other. This module + * collapses all of that into one place. + * + * Every caller still composes its own `patch` and `rows` — the helper just + * applies both atomically with respect to the caller. The order is + * intentional: card first, then activity. If the activity insert blows up, + * the card change already landed, which is the same order every previous + * writer used and matches the "history is allowed to be partial" trade-off + * the rest of the codebase already makes (see logActivity's doc comment). + * + * The `AuditRowInput` type lives in @/lib/activity/diff so unit tests can + * import it without the `server-only` guard here firing. + */ + +import "server-only"; + +import type PocketBase from "pocketbase"; + +import type { AuditRowInput } from "@/lib/activity/diff"; +import type { CardRecord } from "@/lib/pb/types"; + +export type { AuditRowInput }; + +export interface CardPatchWithAudit { + cardId: string; + /** + * PocketBase update payload. Empty object means "no card change" — useful + * for actions that only need to log activity (accept/dismiss resolutions, + * today). The helper still returns the freshly-read card. + */ + patch: Record; + /** + * Activity rows that should be emitted after the card update. Each is + * written in order; a row that throws aborts the helper but does not + * roll back the card change. Callers must build these before calling. + */ + rows: AuditRowInput[]; +} + +/** + * Apply a card patch and emit activity rows. + * + * The PocketBase instance is injected so this module is callable from both + * superuser (admin) contexts — `/api/client/cards/[id]` — and request- + * scoped server-action contexts — `rescheduleCardAction`, the puzzle + * actions. Both need exactly the same code path; what differs is the + * token. + * + * Returns the freshly-read card so callers can chain `getOne`-style reads + * without an extra round-trip. Returns null when the post-update read + * fails — the write itself did succeed, so callers should treat null as + * "trust the patch you sent in" rather than as a failure. + */ +export async function writeCardWithAudit( + pb: PocketBase, + input: CardPatchWithAudit +): Promise { + if (Object.keys(input.patch).length > 0) { + await pb.collection("cards").update(input.cardId, input.patch); + } + + for (const row of input.rows) { + await pb.collection("activity").create(row); + } + + return pb.collection("cards").getOne(input.cardId).catch(() => null); +} + +/** + * Build one activity row for a card-field change. + * + * Caller chooses the action, the field name, and the before/after values. + * A helper rather than a class so the input shape stays JSON-serialisable + * and easy to compose in server-action return paths. + */ +export function auditRow(args: { + project: string; + card: string; + actor: string; + action: import("@/lib/pb/types").ActivityAction; + field: string; + oldValue: string; + newValue: string; +}): AuditRowInput { + return { + project: args.project, + card: args.card, + actor: args.actor, + action: args.action, + field: args.field, + old_value: args.oldValue, + new_value: args.newValue, + }; +} diff --git a/src/lib/pb/types.ts b/src/lib/pb/types.ts index f1c9dce..d86446f 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -9,9 +9,12 @@ export type CreatedByRole = "owner" | "client"; export type ActivityAction = | "card_created" | "card_moved" + | "column_changed" | "card_updated" | "priority_changed" | "date_changed" + | "date_proposed" + | "date_proposal_resolved" | "card_accepted" | "comment_added" | "subtask_toggled" @@ -94,6 +97,17 @@ export interface CardRecord extends BaseRecord { /** The interview question-and-answer pairs. */ transcript: unknown; size_band: "small" | "medium" | "large" | ""; + /** + * Client-proposed target date. Owners do not write this field through the + * Puzzle — they write `target_date` directly. The three proposal fields + * are written together by `clientProposePlanAction` and cleared together + * by `acceptClientProposalAction` / `dismissClientProposalAction`. A row + * with one set and the others unset is logically incoherent; the server + * actions never produce it. + */ + proposed_target_date?: string; + proposed_by?: string; + proposed_at?: string; } export interface SubtaskRecord extends BaseRecord { diff --git a/src/lib/roadmap/slot-date.test.ts b/src/lib/roadmap/slot-date.test.ts new file mode 100644 index 0000000..f356f9e --- /dev/null +++ b/src/lib/roadmap/slot-date.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; + +import { + SLOTS, + isSlotId, + monthLabel, + slotFromDate, + slotToDate, +} from "@/lib/roadmap/slot-date"; + +const today = new Date("2026-09-17T12:00:00Z"); + +describe("SLOTS", () => { + it("lists the three slots in left-to-right order", () => { + expect([...SLOTS]).toEqual(["this_month", "next_month", "month_after"]); + }); +}); + +describe("isSlotId", () => { + it("accepts each slot id", () => { + expect(isSlotId("this_month")).toBe(true); + expect(isSlotId("next_month")).toBe(true); + expect(isSlotId("month_after")).toBe(true); + }); + + it("rejects everything else", () => { + expect(isSlotId("")).toBe(false); + expect(isSlotId("now")).toBe(false); + expect(isSlotId("later")).toBe(false); + expect(isSlotId(null)).toBe(false); + expect(isSlotId(undefined)).toBe(false); + expect(isSlotId(2)).toBe(false); + }); +}); + +describe("slotToDate", () => { + it("writes the 15th of each slot's month, UTC", () => { + expect(slotToDate("this_month", today)).toBe("2026-09-15"); + expect(slotToDate("next_month", today)).toBe("2026-10-15"); + expect(slotToDate("month_after", today)).toBe("2026-11-15"); + }); + + it("rolls over the year boundary for month-after in December", () => { + const december = new Date("2026-12-04T00:00:00Z"); + expect(slotToDate("this_month", december)).toBe("2026-12-15"); + expect(slotToDate("next_month", december)).toBe("2027-01-15"); + expect(slotToDate("month_after", december)).toBe("2027-02-15"); + }); + + it("is symmetric with slotFromDate for every slot id", () => { + for (const slot of SLOTS) { + const date = slotToDate(slot, today); + expect(slotFromDate(date, today)).toBe(slot); + } + }); + + it("is deterministic regardless of clock-time within the same day", () => { + const morning = new Date("2026-09-17T01:00:00Z"); + const evening = new Date("2026-09-17T23:00:00Z"); + expect(slotToDate("this_month", morning)).toBe( + slotToDate("this_month", evening) + ); + }); +}); + +describe("slotFromDate", () => { + it("places the 15th of each window into the matching slot", () => { + expect(slotFromDate("2026-09-15", today)).toBe("this_month"); + expect(slotFromDate("2026-10-15", today)).toBe("next_month"); + expect(slotFromDate("2026-11-15", today)).toBe("month_after"); + }); + + it("is forgiving about the day-of-month within the slot", () => { + // The puzzle always writes the 15th, but a card whose target_date was + // hand-set on a different day should still resolve into its slot — the + // 30-day approximation is exact for mid-month dates and close enough + // for any date that lands within the slot's window. + expect(slotFromDate("2026-09-01", today)).toBe("this_month"); + expect(slotFromDate("2026-09-30", today)).toBe("this_month"); + expect(slotFromDate("2026-10-02", today)).toBe("next_month"); + expect(slotFromDate("2026-10-31", today)).toBe("next_month"); + }); + + it("returns null for a date in the past", () => { + expect(slotFromDate("2026-08-15", today)).toBeNull(); + expect(slotFromDate("2025-12-15", today)).toBeNull(); + }); + + it("returns null for a date beyond the month-after window", () => { + expect(slotFromDate("2026-12-15", today)).toBeNull(); + expect(slotFromDate("2027-01-15", today)).toBeNull(); + }); + + it("returns null for an empty string", () => { + expect(slotFromDate("", today)).toBeNull(); + }); + + it("returns null for a date the puzzle could not have written", () => { + // The 31st is a 30-day-month edge case. Returning null (and the caller + // falls back to the tray) is the safe choice. + expect(slotFromDate("2026-01-31", today)).toBeNull(); + }); + + it("returns null for malformed input rather than throwing", () => { + expect(slotFromDate("not-a-date", today)).toBeNull(); + expect(slotFromDate("2026-13-01", today)).toBeNull(); + }); + + it("survives a PocketBase-style datetime string", () => { + // load.ts reads card.target_date directly; if a row has a datetime + // instead of a date, the slice-and-parse path must still land in the + // right slot. The function operates on a substring (slice(0,10)) so + // callers are expected to pre-trim — verify the contract holds. + expect(slotFromDate("2026-10-15 00:00:00.000Z".slice(0, 10), today)).toBe( + "next_month" + ); + }); +}); + +describe("monthLabel", () => { + it("returns full English month names", () => { + expect(monthLabel("this_month", today)).toBe("September"); + expect(monthLabel("next_month", today)).toBe("October"); + expect(monthLabel("month_after", today)).toBe("November"); + }); + + it("rolls the year forward across the December boundary", () => { + const december = new Date("2026-12-04T00:00:00Z"); + expect(monthLabel("this_month", december)).toBe("December"); + expect(monthLabel("next_month", december)).toBe("January"); + expect(monthLabel("month_after", december)).toBe("February"); + }); +}); + +describe("round-trip — slot → date → slot", () => { + it("returns to the same slot for every slot id", () => { + for (const slot of SLOTS) { + expect(slotFromDate(slotToDate(slot, today), today)).toBe(slot); + } + }); +}); diff --git a/src/lib/roadmap/slot-date.ts b/src/lib/roadmap/slot-date.ts new file mode 100644 index 0000000..428ccc8 --- /dev/null +++ b/src/lib/roadmap/slot-date.ts @@ -0,0 +1,88 @@ +/** + * Slot ↔ target_date mapping for the Plan Puzzle. + * + * The Puzzle places cards into one of three slots — This Month, Next Month, + * Month After — and writes the mid-month (15th) of the slot's month as the + * card's `target_date`. Reads do the reverse: given an ISO date, return the + * slot it lands in, or `null` if the date falls outside the three-month + * window. + * + * Every function takes `now` as an optional argument so unit tests can pin + * the calendar to a fixed instant. Without that the tests would flake + * every time a real-world clock crossed a month boundary mid-run. + * + * Pixel's `plan-puzzle-v1-frontend` branch published a draft of this file + * with the same signatures; this is the canonical implementation on the + * `plan-puzzle-v1-backend` branch and the one the production code imports. + */ + +export type SlotId = "this_month" | "next_month" | "month_after"; + +/** The puzzle's three slots, in left-to-right order. */ +export const SLOTS: readonly SlotId[] = [ + "this_month", + "next_month", + "month_after", +] as const; + +const DAYS_PER_MONTH_MS = 30 * 86_400_000; + +/** True if the value is one of the three slot ids. Narrows `string` for callers. */ +export function isSlotId(value: unknown): value is SlotId { + return ( + value === "this_month" || value === "next_month" || value === "month_after" + ); +} + +/** + * UTC midnight on the 15th of the month `slotIdx` months after `now`'s month. + * + * Mid-month is deliberate: a card whose target_date is the 1st reads as + * "starting now", and the 30th reads as "running out of time". The 15th is + * the only day that doesn't bias either way. + */ +export function slotToDate(slot: SlotId, now: Date = new Date()): string { + const offset = SLOTS.indexOf(slot); + // SlotId is always one of SLOTS; indexOf never returns -1 for valid input. + const time = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 15); + return new Date(time).toISOString().slice(0, 10); +} + +/** + * Which slot a date lands in. Returns `null` when the date is before this + * month, more than two months out, or unparseable — the caller treats `null` + * as "this card lives in the tray, not a slot". + * + * Uses a 30-day month approximation. For mid-month dates (always the 15th, + * written by the puzzle itself) the arithmetic is exact; for dates written + * by hand, like `2026-01-31`, the result can drift by one slot. The + * mitigation is the explicit "no slot → tray" fallback in the caller. + */ +export function slotFromDate(iso: string, now: Date = new Date()): SlotId | null { + if (!iso) return null; + const year = Number(iso.slice(0, 4)); + const month = Number(iso.slice(5, 7)); + if (!Number.isFinite(year) || !Number.isFinite(month)) return null; + + const target = Date.UTC(year, month - 1, 15); + const base = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 15); + const months = Math.round((target - base) / DAYS_PER_MONTH_MS); + + if (months === 0) return "this_month"; + if (months === 1) return "next_month"; + if (months === 2) return "month_after"; + return null; +} + +/** + * "October", "November", "December" — the column header text. + * + * Rendered in en-US for now. The spec says "month names, updates annually" + * — which it does, because the month is computed from `now` each render — + * so no yearly refresh job is needed. + */ +export function monthLabel(slot: SlotId, now: Date = new Date()): string { + const offset = SLOTS.indexOf(slot); + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1)); + return d.toLocaleString("en-US", { month: "long", timeZone: "UTC" }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 523bf30..3041e51 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; import { fileURLToPath } from "node:url"; +const serverOnlyStub = fileURLToPath( + new URL("./src/lib/server-only-stub.ts", import.meta.url) +); + export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src") }, @@ -9,7 +13,15 @@ export default defineConfig({ test: { projects: [ { - resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, + // Unit tests can also import server-only modules (e.g. + // writeCardWithAudit) and replace the guard with the empty stub, + // matching the integration project's alias. + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": serverOnlyStub, + }, + }, test: { name: "unit", environment: "node", @@ -23,9 +35,7 @@ export default defineConfig({ // Replace server-only with the empty stub so integration tests // can import server modules without "imported from a Client // Component module" blowing up at prepare time. - "server-only": fileURLToPath( - new URL("./src/lib/server-only-stub.ts", import.meta.url) - ), + "server-only": serverOnlyStub, }, conditions: ["react-server", "node", "import", "default"], }, @@ -40,4 +50,4 @@ export default defineConfig({ }, ], }, -}); \ No newline at end of file +});