feat(plan): Plan Puzzle v1 backend — proposal fields, server actions, Done-column guards

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.
This commit is contained in:
Dave 2026-09-19 18:35:33 +02:00
parent 9ddf8571f6
commit b3aac9ba4f
17 changed files with 1459 additions and 38 deletions

View file

@ -0,0 +1,86 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* 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);
}
);

View file

@ -427,6 +427,121 @@ async function ensureCardNumbers(pb: PocketBase): Promise<void> {
* 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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.");
}

View file

@ -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<CardRecord>(input.cardId)
.catch(() => null);
if (!card) return { ok: false, message: "Not found" };
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(card.project)
.catch(() => null);
if (!project || project.client !== session.clientId) {
return { ok: false, message: "Not found" };
}
const column = await pb
.collection("columns")
.getOne<ColumnRecord>(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 };
}

View file

@ -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<CardRecord>(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<CardRecord>(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<CardRecord>(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<ColumnRecord>({
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" };
}
}

View file

@ -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();
});
});

View file

@ -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;
}

View file

@ -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<ColumnRecord>(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");

View file

@ -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<ColumnRecord>(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<CardRecord>(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);
}
}
}

View file

@ -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<keyof ClientCardPatch>) {
const next = patch[key];
@ -60,4 +72,4 @@ export function buildActivityRows(
}
return rows;
}
}

View file

@ -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");

View file

@ -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";

View file

@ -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",
});
});
});

View file

@ -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<string, unknown>;
/**
* 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<CardRecord | null> {
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<CardRecord>(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,
};
}

View file

@ -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 {

View file

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

View file

@ -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" });
}

View file

@ -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({
},
],
},
});
});