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
+});