Fourteen tasks covering the card detail page, checklists, comments, a readable activity timeline, card creation for both roles, and the accept flow that stamps start_date. Owner mutations are Server Actions on the caller's own token; client mutations keep going through the whitelisted /api/client routes. Records the constraint that activity.createRule is superuser-only, so audit rows need a dedicated helper even for the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 KiB
Phase 1c — Card Detail, Creation and the Inbox Flow Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the board usable day to day — open a card, edit it, break it into subtasks, hold a conversation on it, create cards as either role, and accept a client's request off the Inbox.
Architecture: Owner mutations are Next.js Server Actions using the owner's own PocketBase token, authorised by the owner-only write rules from Plan 1a. Client-manager mutations keep going through the hardened /api/client/* route handlers, because those need a field whitelist that PocketBase cannot express. Audit rows are written through a single superuser-backed helper, because activity.createRule is superuser-only. Card detail is a real route, not a modal, so a card can be linked to in an email.
Tech Stack: Next.js 16 Server Actions · React 19 · PocketBase 0.30 · Tailwind 4 · vitest
Spec: docs/superpowers/specs/2026-09-16-client-board-phase-1-design.md §4, §8
Prerequisite: Plans 1a and 1b complete. npm test shows 68 passing; npm run build is clean.
Two rules that decide everything in this plan
1. Owner writes are Server Actions. Client writes stay HTTP routes.
This is not stylistic. The two paths need different things:
- A client manager may write exactly two card fields. PocketBase rules are record-level and cannot express that, so their writes go through
/api/client/*, which authenticates as a superuser and copies across only whitelisted fields. Those routes exist, are tested over HTTP, and this plan does not touch them. - The owner may write everything. PocketBase already enforces that through
@request.auth.role = "owner", so no whitelist is needed and no superuser is needed. A Server Action running with the owner's own token is the simplest correct thing, and it avoids hand-writing eight more route handlers.
Do not "simplify" by moving client writes into Server Actions. The whitelist is the security boundary, and it belongs somewhere explicit and separately tested.
2. Audit rows need a superuser, even for the owner
activity.createRule is null, which in PocketBase means superuser only. The owner's own token cannot insert an activity row. Every Server Action in this plan that needs to log something calls the logActivity helper from Task 3, which uses getAdminPb() for that one insert.
If you skip this, app.collection("activity").create(...) fails with a 403 inside an action that otherwise looks correct, and the card mutation appears to work while its history silently goes missing.
Authorisation in every action
Server Actions are public HTTP endpoints. Being unexported from a page does not protect them. Every action in this plan therefore:
- calls
requireSession() - refuses anything that is not
role === "owner" - re-checks that the record belongs to a project the caller may touch
Never rely on the component not rendering a button.
File structure
src/
├── app/
│ ├── actions/
│ │ ├── activity.ts # logActivity — superuser-backed
│ │ ├── cards.ts # create, update, archive, accept
│ │ ├── subtasks.ts # create, toggle, rename, delete
│ │ └── comments.ts # owner comment
│ └── p/[clientSlug]/[projectSlug]/
│ └── c/[cardId]/
│ ├── page.tsx # card detail route
│ └── not-found.tsx
├── components/
│ ├── card/
│ │ ├── card-header.tsx # title + description, owner-editable
│ │ ├── subtask-list.tsx
│ │ ├── comment-thread.tsx
│ │ ├── activity-timeline.tsx
│ │ └── card-actions.tsx # accept / archive, owner only
│ └── board/
│ ├── add-card.tsx # owner: per-column; client: Inbox
│ └── card-tile.tsx # MODIFY — link to detail
└── lib/
├── activity/
│ ├── format.ts # activity row -> human sentence
│ └── format.test.ts
├── time/
│ ├── relative.ts # "3 hours ago"
│ └── relative.test.ts
└── data/
└── card.ts # card detail loader
Task 1: Relative time formatting
Pure logic, test-first. Used by the comment thread and the activity timeline.
Files:
-
Create:
src/lib/time/relative.ts -
Test:
src/lib/time/relative.test.ts -
Step 1: Write the failing tests
src/lib/time/relative.test.ts:
import { describe, it, expect } from "vitest";
import { relativeTime } from "@/lib/time/relative";
const now = new Date("2026-09-16T12:00:00Z");
describe("relativeTime", () => {
it("returns an empty string for a missing value", () => {
expect(relativeTime("", now)).toBe("");
});
it("says just now under a minute", () => {
expect(relativeTime("2026-09-16 11:59:30.000Z", now)).toBe("just now");
});
it("counts minutes", () => {
expect(relativeTime("2026-09-16 11:59:00.000Z", now)).toBe("1 minute ago");
expect(relativeTime("2026-09-16 11:45:00.000Z", now)).toBe("15 minutes ago");
});
it("counts hours", () => {
expect(relativeTime("2026-09-16 11:00:00.000Z", now)).toBe("1 hour ago");
expect(relativeTime("2026-09-16 07:00:00.000Z", now)).toBe("5 hours ago");
});
it("says yesterday", () => {
expect(relativeTime("2026-09-15 08:00:00.000Z", now)).toBe("yesterday");
});
it("counts days up to a week", () => {
expect(relativeTime("2026-09-12 12:00:00.000Z", now)).toBe("4 days ago");
});
it("falls back to a date beyond a week", () => {
expect(relativeTime("2026-09-01 12:00:00.000Z", now)).toBe("1 Sep");
});
it("includes the year for another year", () => {
expect(relativeTime("2025-12-20 12:00:00.000Z", now)).toBe("20 Dec 2025");
});
it("does not produce negative times for a near-future timestamp", () => {
// Clock skew between the PocketBase host and the browser can put a
// just-written record a few seconds in the future. "in -3 seconds" is
// worse than a small lie.
expect(relativeTime("2026-09-16 12:00:05.000Z", now)).toBe("just now");
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/time/relative".
- Step 3: Write the implementation
src/lib/time/relative.ts:
/**
* Human-readable elapsed time.
*
* PocketBase returns timestamps as "2026-09-16 12:00:00.000Z" — a space, not
* the "T" that Date.parse wants everywhere. Normalise before parsing or this
* silently returns NaN on some engines.
*/
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
function parse(value: string): Date {
return new Date(value.replace(" ", "T"));
}
function plural(count: number, unit: string): string {
return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
}
export function relativeTime(value: string, now: Date = new Date()): string {
if (!value) return "";
const then = parse(value);
if (Number.isNaN(then.getTime())) return "";
const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
// Clamp small negatives: clock skew between the database host and here can
// put a record a few seconds in the future.
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return plural(minutes, "minute");
const hours = Math.floor(minutes / 60);
if (hours < 24) return plural(hours, "hour");
const days = Math.floor(hours / 24);
if (days === 1) return "yesterday";
if (days < 7) return plural(days, "day");
const day = then.getUTCDate();
const month = MONTHS[then.getUTCMonth()];
const year = then.getUTCFullYear();
if (year !== now.getUTCFullYear()) return `${day} ${month} ${year}`;
return `${day} ${month}`;
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 9 tests in relative.test.ts.
- Step 5: Commit
git add src/lib/time
git commit -m "feat(time): relative timestamp formatting"
Task 2: Activity humanising
Turns an audit row into a sentence a client can read. Pure, test-first.
Files:
-
Create:
src/lib/activity/format.ts -
Test:
src/lib/activity/format.test.ts -
Step 1: Write the failing tests
src/lib/activity/format.test.ts:
import { describe, it, expect } from "vitest";
import { describeActivity } from "@/lib/activity/format";
import type { ActivityRecord } from "@/lib/pb/types";
function row(overrides: Partial<ActivityRecord>): ActivityRecord {
return {
id: "a1",
created: "2026-09-16 10:00:00.000Z",
project: "p1",
card: "c1",
actor: "u1",
action: "card_updated",
field: "",
old_value: "",
new_value: "",
...overrides,
} as ActivityRecord;
}
describe("describeActivity", () => {
it("describes a priority change in words, not codes", () => {
expect(
describeActivity(
row({ action: "priority_changed", field: "priority", old_value: "medium", new_value: "high" })
)
).toBe("changed priority from Medium to High");
});
it("describes setting a target date that had none", () => {
expect(
describeActivity(
row({ action: "date_changed", field: "target_date", old_value: "", new_value: "2026-10-01" })
)
).toBe("set the target date to 1 Oct 2026");
});
it("describes moving a target date", () => {
expect(
describeActivity(
row({
action: "date_changed",
field: "target_date",
old_value: "2026-10-01 00:00:00.000Z",
new_value: "2026-11-15",
})
)
).toBe("moved the target date from 1 Oct 2026 to 15 Nov 2026");
});
it("describes clearing a target date", () => {
expect(
describeActivity(
row({ action: "date_changed", field: "target_date", old_value: "2026-10-01", new_value: "" })
)
).toBe("cleared the target date");
});
it("describes card creation, acceptance and archiving", () => {
expect(describeActivity(row({ action: "card_created", new_value: "Add a POS system" })))
.toBe("added this card");
expect(describeActivity(row({ action: "card_accepted" }))).toBe("accepted this request");
expect(describeActivity(row({ action: "card_archived" }))).toBe("archived this card");
});
it("names the destination when a card moves", () => {
expect(describeActivity(row({ action: "card_moved", new_value: "In Progress" })))
.toBe("moved this card to In Progress");
});
it("falls back gracefully when a move has no destination recorded", () => {
expect(describeActivity(row({ action: "card_moved", new_value: "" })))
.toBe("moved this card");
});
it("describes comments and subtasks", () => {
expect(describeActivity(row({ action: "comment_added" }))).toBe("commented");
expect(
describeActivity(row({ action: "subtask_toggled", new_value: "true", field: "Wire up the till" }))
).toBe("completed “Wire up the till”");
expect(
describeActivity(row({ action: "subtask_toggled", new_value: "false", field: "Wire up the till" }))
).toBe("reopened “Wire up the till”");
});
it("describes a generic update", () => {
expect(describeActivity(row({ action: "card_updated", field: "description" })))
.toBe("updated the description");
expect(describeActivity(row({ action: "card_updated", field: "title" })))
.toBe("renamed this card");
});
it("never returns an empty string for an unknown action", () => {
// Double assertion on purpose: the point is to simulate an action value
// added by a later phase that this switch has not learned yet.
const unknown = "something_new" as unknown as ActivityRecord["action"];
const result = describeActivity(row({ action: unknown }));
expect(result.length).toBeGreaterThan(0);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/activity/format".
- Step 3: Write the implementation
src/lib/activity/format.ts:
/**
* Renders an audit row as a sentence.
*
* The audience is a client manager, not a developer. Values are written the
* way a person would say them — "High", not "high"; "1 Oct 2026", not an ISO
* timestamp — because this timeline is the record they read when they want to
* know what changed and when.
*
* The actor's name is prepended by the caller, so every string here starts
* with a verb and reads as "<Name> changed priority from Medium to High".
*/
import type { ActivityRecord } from "@/lib/pb/types";
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const PRIORITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
function label(value: string): string {
return PRIORITY_LABELS[value] ?? value;
}
/** "2026-10-01" or "2026-10-01 00:00:00.000Z" -> "1 Oct 2026". */
function longDate(value: string): string {
if (!value) return "";
const year = Number(value.slice(0, 4));
const month = MONTHS[Number(value.slice(5, 7)) - 1];
const day = Number(value.slice(8, 10));
if (!month || Number.isNaN(day)) return value;
return `${day} ${month} ${year}`;
}
export function describeActivity(row: ActivityRecord): string {
switch (row.action) {
case "priority_changed":
return `changed priority from ${label(row.old_value)} to ${label(row.new_value)}`;
case "date_changed": {
const from = longDate(row.old_value);
const to = longDate(row.new_value);
if (!from && to) return `set the target date to ${to}`;
if (from && !to) return "cleared the target date";
return `moved the target date from ${from} to ${to}`;
}
case "card_created":
return "added this card";
case "card_accepted":
return "accepted this request";
case "card_archived":
return "archived this card";
case "card_moved":
return row.new_value
? `moved this card to ${row.new_value}`
: "moved this card";
case "comment_added":
return "commented";
case "subtask_toggled":
return row.new_value === "true"
? `completed “${row.field}”`
: `reopened “${row.field}”`;
case "card_updated":
if (row.field === "title") return "renamed this card";
if (row.field) return `updated the ${row.field}`;
return "updated this card";
default:
// An action added later must still render as something. A blank line in
// a history the client reads is worse than a vague one.
return "made a change";
}
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 11 tests in format.test.ts.
- Step 5: Commit
git add src/lib/activity/format.ts src/lib/activity/format.test.ts
git commit -m "feat(activity): render audit rows as readable sentences"
Task 3: The activity logging helper
Files:
-
Create:
src/app/actions/activity.ts -
Step 1: Write the helper
src/app/actions/activity.ts:
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import type { ActivityAction } from "@/lib/pb/types";
/**
* Write one audit row.
*
* Uses a superuser connection deliberately. `activity.createRule` is null,
* which in PocketBase means superuser-only — the owner's own token is refused,
* so a Server Action cannot insert here with the caller's connection. Without
* this helper the surrounding mutation succeeds and its history silently
* vanishes, which is the failure the audit log exists to prevent.
*
* Never call this before verifying the caller is allowed to make the change
* being logged. It checks nothing.
*/
export async function logActivity(entry: {
project: string;
card?: string;
actor: string;
action: ActivityAction;
field?: string;
oldValue?: string;
newValue?: string;
}): Promise<void> {
const pb = await getAdminPb();
await pb.collection("activity").create({
project: entry.project,
card: entry.card ?? "",
actor: entry.actor,
action: entry.action,
field: entry.field ?? "",
old_value: entry.oldValue ?? "",
new_value: entry.newValue ?? "",
});
}
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Commit
git add src/app/actions/activity.ts
git commit -m "feat(activity): superuser-backed audit log helper"
Task 4: Card detail loader
Files:
-
Create:
src/lib/data/card.ts -
Step 1: Write the loader
src/lib/data/card.ts:
import "server-only";
import { getServerPb } from "@/lib/pb/server";
import type {
ActivityRecord,
CardRecord,
ClientRecord,
ColumnRecord,
CommentRecord,
ProjectRecord,
SubtaskRecord,
UserRecord,
} from "@/lib/pb/types";
export interface CardDetail {
card: CardRecord;
column: ColumnRecord;
project: ProjectRecord;
client: ClientRecord;
columns: ColumnRecord[];
subtasks: SubtaskRecord[];
comments: CommentRecord[];
activity: ActivityRecord[];
/** Display names by user id, for comments and the timeline. */
names: Record<string, string>;
}
/**
* Everything the card detail page needs, read with the caller's own token so
* the Plan 1a read rules do the scoping.
*
* Returns null when the card does not exist OR the caller may not see it. The
* page renders the same 404 for both, so a client cannot discover that another
* client's card is real by probing ids.
*/
export async function loadCardDetail(
clientSlug: string,
projectSlug: string,
cardId: string
): Promise<CardDetail | null> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return null;
try {
const client = await pb
.collection("clients")
.getFirstListItem<ClientRecord>(`slug = "${clientSlug}"`);
const project = await pb
.collection("projects")
.getFirstListItem<ProjectRecord>(
`slug = "${projectSlug}" && client = "${client.id}"`
);
const card = await pb.collection("cards").getOne<CardRecord>(cardId);
// The card must belong to the project in the URL. Without this check a
// valid card id from another project would render under this project's
// breadcrumb — and for a client manager, the read rules would have already
// allowed it if both projects are theirs.
if (card.project !== project.id) return null;
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
filter: `project = "${project.id}"`,
sort: "position",
});
const column = columns.find((c) => c.id === card.column);
if (!column) return null;
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
filter: `card = "${cardId}"`,
sort: "position",
});
const comments = await pb.collection("comments").getFullList<CommentRecord>({
filter: `card = "${cardId}"`,
sort: "created",
});
const activity = await pb.collection("activity").getFullList<ActivityRecord>({
filter: `card = "${cardId}"`,
sort: "-created",
});
const userIds = new Set<string>();
for (const c of comments) if (c.author) userIds.add(c.author);
for (const a of activity) if (a.actor) userIds.add(a.actor);
if (card.created_by) userIds.add(card.created_by);
const names: Record<string, string> = {};
for (const id of userIds) {
const user = await pb
.collection("users")
.getOne<UserRecord>(id)
.catch(() => null);
names[id] = user?.name || user?.email || "Someone";
}
return {
card,
column,
project,
client,
columns,
subtasks,
comments,
activity,
names,
};
} catch {
return null;
}
}
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Commit
git add src/lib/data/card.ts
git commit -m "feat(data): card detail loader scoped by read rules"
Task 5: Owner card actions
Files:
-
Create:
src/app/actions/cards.ts -
Step 1: Write the actions
src/app/actions/cards.ts:
"use server";
import { revalidatePath } from "next/cache";
import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { logActivity } from "@/app/actions/activity";
import { positionBetween } from "@/lib/ordering/fractional";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
/**
* Owner mutations.
*
* SECURITY: a Server Action is a public HTTP endpoint. Not rendering the
* button that calls it protects nothing. Every action below re-checks the
* session and the role before touching anything, and PocketBase's owner-only
* write rules refuse the request a second time if one of them is ever missed.
*
* These use the caller's OWN PocketBase token — no superuser — precisely so
* that second check is real. The single exception is logActivity, which needs
* a superuser because activity.createRule is superuser-only.
*/
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") {
throw new Error("Not found");
}
return session;
}
function boardPath(clientSlug: string, projectSlug: string): string {
return `/p/${clientSlug}/${projectSlug}`;
}
export async function createCard(input: {
projectId: string;
columnId: string;
title: string;
clientSlug: string;
projectSlug: string;
}): Promise<{ id: string }> {
const session = await requireOwner();
const pb = await getServerPb();
const title = input.title.trim().slice(0, 200);
if (!title) throw new Error("A title is required");
const last = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${input.columnId}"`, {
sort: "-position",
})
.catch(() => null);
const card = await pb.collection("cards").create<CardRecord>({
project: input.projectId,
column: input.columnId,
title,
description: "",
position: positionBetween(last ? last.position : null, null),
priority: "medium",
created_by: session.userId,
created_by_role: "owner",
accepted: true,
archived: false,
});
await logActivity({
project: input.projectId,
card: card.id,
actor: session.userId,
action: "card_created",
newValue: title,
});
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
return { id: card.id };
}
export async function updateCard(input: {
cardId: string;
title?: string;
description?: string;
scopeRule?: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
const patch: Record<string, unknown> = {};
if (input.title !== undefined) {
const title = input.title.trim().slice(0, 200);
if (!title) throw new Error("A title is required");
patch.title = title;
}
if (input.description !== undefined) {
patch.description = input.description.slice(0, 50000);
}
if (input.scopeRule !== undefined) {
patch.scope_rule = input.scopeRule.trim().slice(0, 60);
}
if (Object.keys(patch).length === 0) return;
await pb.collection("cards").update(input.cardId, patch);
// One row per field, matching how client edits are logged, so the timeline
// reads consistently regardless of who made the change.
for (const field of Object.keys(patch)) {
if (field === "scope_rule") continue; // internal, not client-facing
await logActivity({
project: card.project,
card: card.id,
actor: session.userId,
action: "card_updated",
field: field === "title" ? "title" : "description",
});
}
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/c/${input.cardId}`);
}
/**
* Accept a client's request off the Inbox.
*
* Sets `accepted` and stamps `start_date`. The start date is not decoration:
* Phase 2 uses it to bound which commits a scope rule may claim, so a new card
* cannot retroactively swallow a year of old commits sharing its scope.
*
* The card moves to the column immediately after the Inbox — Backlog in the
* default layout — rather than to a hardcoded name, so a renamed board still
* works.
*/
export async function acceptCard(input: {
cardId: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
filter: `project = "${card.project}"`,
sort: "position",
});
const inboxIndex = columns.findIndex((c) => c.is_inbox);
const destination = columns[inboxIndex + 1] ?? columns[inboxIndex];
if (!destination) throw new Error("This project has no column to accept into");
const last = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${destination.id}"`, {
sort: "-position",
})
.catch(() => null);
const today = new Date().toISOString().slice(0, 10);
await pb.collection("cards").update(input.cardId, {
accepted: true,
start_date: card.start_date || today,
column: destination.id,
position: positionBetween(last ? last.position : null, null),
});
await logActivity({
project: card.project,
card: card.id,
actor: session.userId,
action: "card_accepted",
newValue: destination.name,
});
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
}
export async function archiveCard(input: {
cardId: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
await pb.collection("cards").update(input.cardId, { archived: true });
await logActivity({
project: card.project,
card: card.id,
actor: session.userId,
action: "card_archived",
});
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
}
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Commit
git add src/app/actions/cards.ts
git commit -m "feat(actions): owner card create, update, accept and archive"
Task 6: Subtask and comment actions
Files:
-
Create:
src/app/actions/subtasks.ts -
Create:
src/app/actions/comments.ts -
Step 1: Write the subtask actions
src/app/actions/subtasks.ts:
"use server";
import { revalidatePath } from "next/cache";
import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { logActivity } from "@/app/actions/activity";
import { positionBetween } from "@/lib/ordering/fractional";
import type { CardRecord, SubtaskRecord } from "@/lib/pb/types";
/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
function cardPath(clientSlug: string, projectSlug: string, cardId: string) {
return `/p/${clientSlug}/${projectSlug}/c/${cardId}`;
}
export async function addSubtask(input: {
cardId: string;
title: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
await requireOwner();
const pb = await getServerPb();
const title = input.title.trim().slice(0, 200);
if (!title) return;
const last = await pb
.collection("subtasks")
.getFirstListItem<SubtaskRecord>(`card = "${input.cardId}"`, {
sort: "-position",
})
.catch(() => null);
await pb.collection("subtasks").create({
card: input.cardId,
title,
done: false,
position: positionBetween(last ? last.position : null, null),
});
revalidatePath(cardPath(input.clientSlug, input.projectSlug, input.cardId));
}
export async function toggleSubtask(input: {
subtaskId: string;
done: boolean;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
const session = await requireOwner();
const pb = await getServerPb();
const subtask = await pb
.collection("subtasks")
.getOne<SubtaskRecord>(input.subtaskId);
await pb.collection("subtasks").update(input.subtaskId, { done: input.done });
const card = await pb.collection("cards").getOne<CardRecord>(subtask.card);
await logActivity({
project: card.project,
card: card.id,
actor: session.userId,
action: "subtask_toggled",
// `field` carries the subtask's title so the timeline can name it without
// a second lookup, and still reads correctly after the subtask is deleted.
field: subtask.title,
newValue: String(input.done),
});
revalidatePath(cardPath(input.clientSlug, input.projectSlug, subtask.card));
}
export async function deleteSubtask(input: {
subtaskId: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
await requireOwner();
const pb = await getServerPb();
const subtask = await pb
.collection("subtasks")
.getOne<SubtaskRecord>(input.subtaskId);
await pb.collection("subtasks").delete(input.subtaskId);
revalidatePath(cardPath(input.clientSlug, input.projectSlug, subtask.card));
}
- Step 2: Write the comment action
src/app/actions/comments.ts:
"use server";
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";
/**
* The OWNER's comment path only.
*
* A client manager comments through POST /api/client/cards/:id/comments, built
* in Plan 1a. Do not route them here: that route verifies the card belongs to
* their client and runs under a superuser with an explicit whitelist, which is
* the boundary this action deliberately does not have.
*/
export async function addOwnerComment(input: {
cardId: string;
body: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
const body = input.body.trim().slice(0, 20000);
if (!body) return;
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
await pb.collection("comments").create({
card: input.cardId,
author: session.userId,
body,
});
await logActivity({
project: card.project,
card: card.id,
actor: session.userId,
action: "comment_added",
});
revalidatePath(
`/p/${input.clientSlug}/${input.projectSlug}/c/${input.cardId}`
);
}
- Step 3: Verify the build
npm run build
Expected: build succeeds.
- Step 4: Commit
git add src/app/actions
git commit -m "feat(actions): subtask and owner comment mutations"
Task 7: Subtask list component
Files:
-
Create:
src/components/card/subtask-list.tsx -
Step 1: Write the component
src/components/card/subtask-list.tsx:
"use client";
import { useState, useTransition } from "react";
import { Plus, X } from "lucide-react";
import { addSubtask, deleteSubtask, toggleSubtask } from "@/app/actions/subtasks";
import { cn } from "@/lib/cn";
import type { SubtaskRecord } from "@/lib/pb/types";
export function SubtaskList({
cardId,
subtasks,
editable,
clientSlug,
projectSlug,
}: {
cardId: string;
subtasks: SubtaskRecord[];
editable: boolean;
clientSlug: string;
projectSlug: string;
}) {
const [pending, startTransition] = useTransition();
const [draft, setDraft] = useState("");
const done = subtasks.filter((s) => s.done).length;
const percent = subtasks.length
? Math.round((done / subtasks.length) * 100)
: 0;
function submit(event: React.FormEvent) {
event.preventDefault();
const title = draft.trim();
if (!title) return;
setDraft("");
startTransition(() =>
addSubtask({ cardId, title, clientSlug, projectSlug }).then(() => undefined)
);
}
return (
<section className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Checklist
</h2>
{subtasks.length > 0 && (
<>
<span className="tabular text-xs text-text-muted">
{done}/{subtasks.length}
</span>
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-raised"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Checklist progress"
>
<div
className="h-full rounded-full bg-brand transition-[width]"
style={{ width: `${percent}%` }}
/>
</div>
</>
)}
</div>
{subtasks.length === 0 && !editable && (
<p className="text-sm text-text-muted">No checklist items yet.</p>
)}
<ul className="space-y-1">
{subtasks.map((subtask) => (
<li key={subtask.id} className="group flex items-center gap-2.5">
<input
type="checkbox"
checked={subtask.done}
disabled={!editable || pending}
onChange={(event) =>
startTransition(() =>
toggleSubtask({
subtaskId: subtask.id,
done: event.target.checked,
clientSlug,
projectSlug,
}).then(() => undefined)
)
}
className="h-4 w-4 shrink-0 rounded border-border-strong accent-[var(--brand)]"
/>
<span
className={cn(
"flex-1 text-sm",
subtask.done
? "text-text-muted line-through"
: "text-text-primary"
)}
>
{subtask.title}
</span>
{editable && (
<button
type="button"
aria-label={`Delete ${subtask.title}`}
disabled={pending}
onClick={() =>
startTransition(() =>
deleteSubtask({
subtaskId: subtask.id,
clientSlug,
projectSlug,
}).then(() => undefined)
)
}
className="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
>
<X size={14} strokeWidth={2} className="text-text-muted" />
</button>
)}
</li>
))}
</ul>
{editable && (
<form onSubmit={submit} className="flex items-center gap-2">
<Plus size={14} strokeWidth={2} className="text-text-muted" />
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Add an item"
disabled={pending}
className="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
/>
</form>
)}
</section>
);
}
- Step 2: Commit
git add src/components/card/subtask-list.tsx
git commit -m "feat(card): checklist with progress and owner editing"
The build will not be exercised until Task 10 renders this. That is expected.
Task 8: Comment thread
Files:
- Create:
src/components/card/comment-thread.tsx
The two paths. The owner posts through the addOwnerComment Server Action; a client manager posts through POST /api/client/cards/:id/comments. Both exist for good reasons — see the header comment in src/app/actions/comments.ts. This component picks the right one from the role and must not be "simplified" into a single path.
- Step 1: Write the component
src/components/card/comment-thread.tsx:
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { addOwnerComment } from "@/app/actions/comments";
import { relativeTime } from "@/lib/time/relative";
import type { CommentRecord, Role } from "@/lib/pb/types";
export function CommentThread({
cardId,
comments,
names,
role,
clientSlug,
projectSlug,
}: {
cardId: string;
comments: CommentRecord[];
names: Record<string, string>;
role: Role;
clientSlug: string;
projectSlug: string;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
event.preventDefault();
const body = draft.trim();
if (!body) return;
setError(null);
if (role === "owner") {
setDraft("");
startTransition(() =>
addOwnerComment({ cardId, body, clientSlug, projectSlug }).then(
() => undefined
)
);
return;
}
// Client managers post through the hardened route, not the action.
const response = await fetch(`/api/client/cards/${cardId}/comments`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ body }),
});
if (!response.ok) {
setError("Could not post that comment");
return;
}
setDraft("");
router.refresh();
}
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Comments
</h2>
{comments.length === 0 && (
<p className="text-sm text-text-muted">No comments yet.</p>
)}
<ul className="space-y-3">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3"
>
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-text-primary">
{names[comment.author] ?? "Someone"}
</span>
<span className="text-xs text-text-muted">
{relativeTime(comment.created)}
</span>
</div>
{/*
Rendered as plain text on purpose. The body is markdown authored
by a client and is never passed to dangerouslySetInnerHTML.
whitespace-pre-wrap preserves their line breaks without
interpreting anything.
*/}
<p className="mt-1.5 whitespace-pre-wrap text-sm text-text-secondary">
{comment.body}
</p>
</li>
))}
</ul>
<form onSubmit={submit} className="space-y-2">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
placeholder="Write a comment"
disabled={pending}
className="w-full rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3 text-sm text-text-primary outline-none placeholder:text-text-muted focus:border-brand"
/>
{error && (
<p role="alert" className="text-xs text-accent">
{error}
</p>
)}
<button
type="submit"
disabled={pending || !draft.trim()}
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
>
{pending ? "Posting…" : "Comment"}
</button>
</form>
</section>
);
}
- Step 2: Commit
git add src/components/card/comment-thread.tsx
git commit -m "feat(card): comment thread with role-appropriate write path"
Task 9: Activity timeline
Files:
-
Create:
src/components/card/activity-timeline.tsx -
Step 1: Write the component
src/components/card/activity-timeline.tsx:
import { describeActivity } from "@/lib/activity/format";
import { relativeTime } from "@/lib/time/relative";
import type { ActivityRecord } from "@/lib/pb/types";
/**
* The card's history, newest first.
*
* This is the answer to "I never agreed to that date". It is shown to the
* client as well as the owner deliberately — a log only one side can see
* settles nothing.
*/
export function ActivityTimeline({
activity,
names,
}: {
activity: ActivityRecord[];
names: Record<string, string>;
}) {
if (activity.length === 0) {
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
History
</h2>
<p className="text-sm text-text-muted">Nothing has changed yet.</p>
</section>
);
}
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
History
</h2>
<ol className="space-y-2.5">
{activity.map((row) => (
<li key={row.id} className="flex gap-2.5 text-sm">
<span
aria-hidden
className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-border-strong"
/>
<p className="text-text-secondary">
<span className="font-medium text-text-primary">
{names[row.actor] ?? "Someone"}
</span>{" "}
{describeActivity(row)}
<span className="text-text-muted">
{" · "}
{relativeTime(row.created)}
</span>
</p>
</li>
))}
</ol>
</section>
);
}
- Step 2: Commit
git add src/components/card/activity-timeline.tsx
git commit -m "feat(card): activity timeline visible to both roles"
Task 10: Card detail page
Files:
-
Create:
src/components/card/card-header.tsx -
Create:
src/components/card/card-actions.tsx -
Create:
src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx -
Step 1: Write the header
src/components/card/card-header.tsx:
"use client";
import { useState, useTransition } from "react";
import { updateCard } from "@/app/actions/cards";
import type { CardRecord } from "@/lib/pb/types";
/**
* Title and description.
*
* Editable in place for the owner, read-only for a client manager. The client
* may not change these — their two editable fields are priority and target
* date, which live on the board tile and go through /api/client/*.
*/
export function CardHeader({
card,
editable,
clientSlug,
projectSlug,
}: {
card: CardRecord;
editable: boolean;
clientSlug: string;
projectSlug: string;
}) {
const [pending, startTransition] = useTransition();
const [title, setTitle] = useState(card.title);
const [description, setDescription] = useState(card.description);
function saveTitle() {
if (title.trim() === card.title || !title.trim()) {
setTitle(card.title);
return;
}
startTransition(() =>
updateCard({ cardId: card.id, title, clientSlug, projectSlug }).then(
() => undefined
)
);
}
function saveDescription() {
if (description === card.description) return;
startTransition(() =>
updateCard({ cardId: card.id, description, clientSlug, projectSlug }).then(
() => undefined
)
);
}
if (!editable) {
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold tracking-tight text-text-primary">
{card.title}
</h1>
{card.description ? (
<p className="whitespace-pre-wrap text-sm text-text-secondary">
{card.description}
</p>
) : (
<p className="text-sm text-text-muted">No description yet.</p>
)}
</div>
);
}
return (
<div className="space-y-3">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
onBlur={saveTitle}
disabled={pending}
aria-label="Card title"
className="w-full rounded-lg bg-transparent text-xl font-semibold tracking-tight text-text-primary outline-none hover:bg-surface focus:bg-surface"
/>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
onBlur={saveDescription}
disabled={pending}
rows={5}
placeholder="Add a description"
aria-label="Card description"
className="w-full rounded-[var(--radius-tile)] bg-transparent p-2 text-sm text-text-secondary outline-none placeholder:text-text-muted hover:bg-surface focus:bg-surface"
/>
</div>
);
}
- Step 2: Write the card actions
src/components/card/card-actions.tsx:
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Archive } from "lucide-react";
import { acceptCard, archiveCard } from "@/app/actions/cards";
import type { CardRecord } from "@/lib/pb/types";
export function CardActions({
card,
clientSlug,
projectSlug,
}: {
card: CardRecord;
clientSlug: string;
projectSlug: string;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const showAccept = !card.accepted && card.created_by_role === "client";
return (
<div className="flex items-center gap-2">
{showAccept && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() =>
acceptCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
router.push(`/p/${clientSlug}/${projectSlug}`)
)
)
}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
>
<Check size={14} strokeWidth={2} />
Accept request
</button>
)}
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() =>
archiveCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
router.push(`/p/${clientSlug}/${projectSlug}`)
)
)
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-raised disabled:opacity-50"
>
<Archive size={14} strokeWidth={2} />
Archive
</button>
</div>
);
}
- Step 3: Write the page
src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx:
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { CardHeader } from "@/components/card/card-header";
import { CardActions } from "@/components/card/card-actions";
import { SubtaskList } from "@/components/card/subtask-list";
import { CommentThread } from "@/components/card/comment-thread";
import { ActivityTimeline } from "@/components/card/activity-timeline";
import { Pill } from "@/components/ui/pill";
import { getSession } from "@/lib/auth/session";
import { loadCardDetail } from "@/lib/data/card";
export default async function CardPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string; cardId: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { clientSlug, projectSlug, cardId } = await params;
const detail = await loadCardDetail(clientSlug, projectSlug, cardId);
if (!detail) notFound();
const isOwner = session.role === "owner";
const boardHref = `/p/${clientSlug}/${projectSlug}`;
return (
<AppShell role={session.role} currentPath={boardHref}>
<TopBar title={detail.project.name} subtitle={detail.client.name}>
{isOwner && (
<CardActions
card={detail.card}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
)}
</TopBar>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="flex items-center gap-3">
<Link
href={boardHref}
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
>
<ArrowLeft size={13} strokeWidth={2} />
Back to board
</Link>
<Pill tone="neutral">{detail.column.name}</Pill>
{!detail.card.accepted &&
detail.card.created_by_role === "client" && (
<Pill tone="brand">Requested</Pill>
)}
</div>
<CardHeader
card={detail.card}
editable={isOwner}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<SubtaskList
cardId={detail.card.id}
subtasks={detail.subtasks}
editable={isOwner}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<CommentThread
cardId={detail.card.id}
comments={detail.comments}
names={detail.names}
role={session.role}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<ActivityTimeline activity={detail.activity} names={detail.names} />
</div>
</div>
</AppShell>
);
}
- Step 4: Verify the build
npm run build
Expected: build succeeds; the route list includes /p/[clientSlug]/[projectSlug]/c/[cardId].
- Step 5: Commit
git add src/components/card src/app/p
git commit -m "feat(card): detail page with checklist, comments and history"
Task 11: Open a card from the board
Files:
- Modify:
src/components/board/card-tile.tsx - Modify:
src/components/board/board.tsx
The conflict to be careful about. A card tile is both a drag handle and a link. If the title is a plain <a>, every drag ends in a navigation. The fix is a dedicated open control that is not part of the drag listeners, rather than making the whole tile a link.
- Step 1: Add an open control to the tile
In src/components/board/card-tile.tsx, add to the imports:
import Link from "next/link";
import { Maximize2 } from "lucide-react";
Extend the component's props with href:
export const CardTile = forwardRef<
HTMLDivElement,
{
card: CardRecord;
dragging?: boolean;
href?: string;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(function CardTile({ card, dragging, href, className, children, ...props }, ref) {
Then replace the title paragraph with this block, which keeps the title as text and puts the navigation on its own control:
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 text-[13px] font-medium leading-[1.45] text-text-primary">
{card.title}
</p>
{href && (
<Link
href={href}
aria-label={`Open ${card.title}`}
// Stops the pointer reaching the drag listeners on the tile, so
// opening a card never begins a drag.
onPointerDown={(event) => event.stopPropagation()}
className="shrink-0 rounded p-0.5 text-text-muted opacity-0 transition-opacity hover:text-text-primary focus-visible:opacity-100 group-hover/tile:opacity-100"
>
<Maximize2 size={13} strokeWidth={2} />
</Link>
)}
</div>
Add group/tile to the tile's own className list so the hover reveal works:
className={cn(
"group/tile rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-3",
"shadow-[var(--shadow-tile)] transition-shadow",
dragging && "shadow-[var(--shadow-drag)] opacity-90",
className
)}
- Step 2: Pass the href from the board
In src/components/board/board.tsx, extend Board's props and thread the slugs through to SortableCard:
export function Board({
role,
projectId,
clientSlug,
projectSlug,
columns,
initialCards,
}: {
role: Role;
projectId: string;
clientSlug: string;
projectSlug: string;
columns: ColumnRecord[];
initialCards: CardRecord[];
}) {
Add the same two props to SortableCard's signature and pass href to CardTile:
function SortableCard({
card,
role,
clientSlug,
projectSlug,
onPatched,
}: {
card: CardRecord;
role: Role;
clientSlug: string;
projectSlug: string;
onPatched: (card: CardRecord) => void;
}) {
<CardTile
ref={setNodeRef}
card={card}
dragging={isDragging}
href={`/p/${clientSlug}/${projectSlug}/c/${card.id}`}
style={{ transform: CSS.Translate.toString(transform), transition }}
And in the list.map inside content:
<SortableCard
key={card.id}
card={card}
role={role}
clientSlug={clientSlug}
projectSlug={projectSlug}
onPatched={onPatched}
/>
- Step 3: Pass the slugs from the board page
In src/app/p/[clientSlug]/[projectSlug]/page.tsx, extend the <Board> element:
<Board
role={session.role}
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
columns={board.columns}
initialCards={board.cards}
/>
- Step 4: Verify the build and behaviour
npm run build
Expected: build succeeds. Then, signed in as the owner, hover a card — an open icon appears top-right. Click it to reach the detail page. Drag the card by its body and confirm no navigation happens.
- Step 5: Commit
git add src/components/board src/app/p
git commit -m "feat(board): open a card without breaking drag"
Task 12: Creating cards
Files:
- Create:
src/components/board/add-card.tsx - Modify:
src/components/board/board.tsx
Two different things with one shape. The owner adds a card directly into any column through the createCard action. A client manager submits a request, which always lands in the Inbox as unaccepted, through POST /api/client/cards. The component renders in a column only when that role may add there.
- Step 1: Write the component
src/components/board/add-card.tsx:
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { createCard } from "@/app/actions/cards";
import type { Role } from "@/lib/pb/types";
export function AddCard({
role,
projectId,
columnId,
clientSlug,
projectSlug,
}: {
role: Role;
projectId: string;
columnId: string;
clientSlug: string;
projectSlug: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// Takes the minimal shape rather than React.FormEvent, because it is called
// both from onSubmit and from the Enter handler in onKeyDown, and those
// carry different event types.
async function submit(event: { preventDefault: () => void }) {
event.preventDefault();
const value = title.trim();
if (!value) return;
setError(null);
if (role === "owner") {
setTitle("");
setOpen(false);
startTransition(() =>
createCard({
projectId,
columnId,
title: value,
clientSlug,
projectSlug,
}).then(() => undefined)
);
return;
}
// A client manager's card is a request: the route decides the column,
// the priority and the accepted flag, whatever is sent here.
const response = await fetch("/api/client/cards", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: projectId, title: value }),
});
if (!response.ok) {
setError("Could not add that");
return;
}
setTitle("");
setOpen(false);
router.refresh();
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 rounded-[var(--radius-tile)] px-2 py-2 text-xs font-medium text-text-muted transition-colors hover:bg-surface-raised hover:text-text-secondary"
>
<Plus size={13} strokeWidth={2} />
{role === "owner" ? "Add a card" : "Request a feature"}
</button>
);
}
return (
<form
onSubmit={submit}
className="rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-2"
>
<textarea
autoFocus
value={title}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit(event);
}
}}
rows={2}
placeholder={
role === "owner" ? "What needs doing?" : "What would you like added?"
}
className="w-full resize-none bg-transparent text-[13px] leading-[1.45] text-text-primary outline-none placeholder:text-text-muted"
/>
{error && (
<p role="alert" className="px-0.5 pb-1 text-[11px] text-accent">
{error}
</p>
)}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={pending || !title.trim()}
className="rounded-md bg-brand px-2.5 py-1 text-xs font-medium text-brand-fg disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-xs text-text-muted hover:text-text-secondary"
>
Cancel
</button>
</div>
</form>
);
}
- Step 2: Render it in the right columns
In src/components/board/board.tsx, import it:
import { AddCard } from "@/components/board/add-card";
Then inside the columns.map in content, after the </SortableContext>, add:
{(role === "owner" || column.is_inbox) && (
<AddCard
role={role}
projectId={projectId}
columnId={column.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
)}
The owner gets it in every column. A client manager gets it only in the Inbox — which is also the only place their card can land, so the UI and the route agree.
- Step 3: Verify the build and behaviour
npm run build
Expected: build succeeds. As the owner, add a card to "To Do" and confirm it appears and survives a reload. As the client manager, confirm "Request a feature" appears only under Inbox, and that a card added there shows the "Requested" pill.
- Step 4: Commit
git add src/components/board
git commit -m "feat(board): add cards as owner, request features as client"
Task 13: Integration tests for the new paths
Two behaviours here can break silently, so they get tests rather than a manual check.
Files:
-
Create:
tests/integration/owner-actions.test.ts -
Step 1: Write the tests
tests/integration/owner-actions.test.ts:
import { describe, it, expect, beforeAll } from "vitest";
import PocketBase from "pocketbase";
import { APP_URL, DEMO, adminPb, requireServices, signIn, userPb } from "./helpers";
let admin: PocketBase;
let managerCookie: string;
let projectId: string;
let inboxId: string;
beforeAll(async () => {
await requireServices();
admin = await adminPb();
managerCookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
projectId = project.id;
inboxId = (
await admin
.collection("columns")
.getFirstListItem(`project = "${projectId}" && is_inbox = true`)
).id;
});
describe("a client manager cannot write card fields the owner owns", () => {
it("is refused by PocketBase on a direct subtask create", async () => {
const card = await admin.collection("cards").getFirstListItem(
`project = "${projectId}"`
);
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
await expect(
pb.collection("subtasks").create({
card: card.id,
title: "injected",
done: false,
position: 1000,
})
).rejects.toThrow();
});
it("is refused by PocketBase on a direct activity insert", async () => {
// activity.createRule is superuser-only. If this ever succeeds, someone
// relaxed the rule and the audit log can be forged.
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
await expect(
pb.collection("activity").create({
project: projectId,
actor: "x",
action: "card_accepted",
})
).rejects.toThrow();
});
});
describe("a client request carries the flags the accept flow depends on", () => {
it("lands unaccepted, client-authored, with no start date", async () => {
const response = await fetch(`${APP_URL}/api/client/cards`, {
method: "POST",
headers: { "content-type": "application/json", cookie: managerCookie },
body: JSON.stringify({ project: projectId, title: "Accept-flow probe" }),
});
expect(response.status).toBe(201);
const { card } = await response.json();
const created = await admin.collection("cards").getOne(card.id);
expect(created.column).toBe(inboxId);
expect(created.accepted).toBe(false);
expect(created.created_by_role).toBe("client");
// start_date must be empty until acceptance — Phase 2 uses it to bound
// which commits this card may claim.
expect(created.start_date).toBeFalsy();
await admin.collection("cards").delete(card.id);
});
});
- Step 2: Run the integration suite
With npm run dev running:
set -a && source .env.local && set +a && npm run test:integration
Expected: PASS — 13 tests total (the 10 from Plan 1a plus 3 here).
- Step 3: Commit
git add tests/integration/owner-actions.test.ts
git commit -m "test(actions): cover audit-log immunity and request flags"
Task 14: Full verification
This task is work, not a summary. Run every command and act on what it prints.
- Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all
Expected: PASS. Unit tests total 88 — the 68 from Plans 1a and 1b, plus 9 in relative.test.ts and 11 in format.test.ts. Integration tests total 13.
- Step 2: Verify the production build
npm run build
Expected: no type errors. The route list includes /p/[clientSlug]/[projectSlug]/c/[cardId].
- Step 3: Run the hardcoded-colour check
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app
Expected: no output. If anything prints, fix it before continuing — this check caught a bug in Plan 1b that shipped because the step was skipped.
- Step 4: Walk the Inbox flow by hand
As the client manager: open the board, use "Request a feature" in the Inbox, add a card. Open it and leave a comment.
As the owner: open the same card. Confirm the "Requested" pill and an "Accept request" button. Accept it.
Expected: the card moves out of Inbox into the next column, the "Requested" pill disappears, start_date is set to today, and the history shows both the client's creation and your acceptance, each named and timestamped.
- Step 5: Check both themes on the new page
Open a card detail page in light and in dark. Confirm the checklist progress bar, comment boxes and history dots are all legible in both.
- Step 6: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 1c verification"
Definition of done
npm run test:allpasses: 88 unit, 13 integration.npm run buildsucceeds with no type errors.- The colour check in Task 14 Step 3 prints nothing.
- The owner can add a card to any column, open it, rename it, write a description, add and tick checklist items, comment, and archive it.
- A client manager can request a feature into the Inbox, comment on a card, and read the full history — and can do nothing else.
- Accepting a request moves it out of the Inbox, sets
acceptedand stampsstart_date. - Dragging a card never navigates; the open control never starts a drag.
Deliberately not in this plan
Plan 1d: admin pages for clients, projects and users; column management; an archived-cards view; the AI settings screen.
Phases 2–5 per the spec: Forgejo ingestion and the effort dashboard, AI humanisation of commit history, the client intake wizard, and the roadmap view.
Also still open, carried forward from the Plan 1b review:
- True PocketBase realtime, which needs the auth token in the browser. Task 12 of Plan 1b uses refresh-on-focus instead.
src/lib/pb/browser.tsis unused by anything. Delete it, or keep it for the realtime decision — but do not import it without revisiting the httpOnly cookie tradeoff.