Add project work areas to Kanban and Plan

This commit is contained in:
Dave 2026-09-22 21:46:35 +02:00
parent 8452c9d5e7
commit 20639264bd
43 changed files with 729 additions and 47 deletions

View file

@ -13,6 +13,7 @@
"seed": "tsx scripts/seed-demo.ts",
"setup-remote": "tsx scripts/setup-remote.ts",
"setup-plan-schema": "tsx scripts/apply-plan-schema.ts",
"setup-work-area-schema": "tsx scripts/apply-work-area-schema.ts",
"lint": "next lint"
},
"dependencies": {

View file

@ -0,0 +1,20 @@
import PocketBase from "pocketbase";
import { ensureWorkAreaSchema } from "./work-area-schema";
async function main() {
const url = process.env.NEXT_PUBLIC_PB_URL;
const email = process.env.PB_SUPERUSER_EMAIL;
const password = process.env.PB_SUPERUSER_PASSWORD;
if (!url || !email || !password) throw new Error("PocketBase URL and superuser credentials are required");
const pb = new PocketBase(url);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(email, password);
const changed = await ensureWorkAreaSchema(pb);
console.log(changed.length ? `Added ${changed.join(", ")}` : "Work area schema already exists");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -17,6 +17,7 @@
import PocketBase from "pocketbase";
import { ensureProjectPlanDataField } from "./plan-schema";
import { ensureWorkAreaSchema } from "./work-area-schema";
const PB_URL =
process.env.NEXT_PUBLIC_PB_URL ??
@ -1558,6 +1559,7 @@ async function main(): Promise<void> {
console.log((await ensureProjectPlanDataField(pb))
? " + projects field: plan_data"
: " = projects.plan_data already present");
for (const change of await ensureWorkAreaSchema(pb)) console.log(` + ${change}`);
// Add the per-commit summary fields used by Phase 3b's AI one-liner job.
await ensureCommitSummaryFields(pb);

View file

@ -0,0 +1,41 @@
import type PocketBase from "pocketbase";
/** Targeted, repeatable schema addition for task areas. Existing card rows are untouched. */
export async function ensureWorkAreaSchema(pb: PocketBase): Promise<string[]> {
const changed: string[] = [];
const projects = await pb.collections.getOne("projects");
let areas = await pb.collections.getOne("work_areas").catch(() => null);
if (!areas) {
areas = await pb.collections.create({
name: "work_areas",
type: "base",
listRule: '@request.auth.id != "" && (@request.auth.role = "owner" || project.client = @request.auth.client)',
viewRule: '@request.auth.id != "" && (@request.auth.role = "owner" || project.client = @request.auth.client)',
createRule: '@request.auth.role = "owner"',
updateRule: '@request.auth.role = "owner"',
deleteRule: '@request.auth.role = "owner"',
fields: [
{ name: "project", type: "relation", required: true, collectionId: projects.id, maxSelect: 1, cascadeDelete: true },
{ name: "name", type: "text", required: true, max: 80, presentable: true },
{ name: "color", type: "text", required: true, max: 20 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_work_areas_project_name ON work_areas (project, lower(name))"],
});
changed.push("work_areas collection");
}
if (!areas) throw new Error("Could not create work_areas");
const cards = await pb.collections.getOne("cards");
if (!cards.fields.some((field: { name: string }) => field.name === "work_area")) {
await pb.collections.update(cards.id, {
fields: [...(cards.fields as unknown[]), {
name: "work_area", type: "relation", required: false,
collectionId: areas.id, maxSelect: 1, cascadeDelete: false,
}],
});
changed.push("cards.work_area field");
}
return changed;
}

View file

@ -8,6 +8,8 @@ import { logActivity } from "@/app/actions/activity";
import { positionBetween } from "@/lib/ordering/fractional";
import { nextCardNumber } from "@/lib/cards/number";
import { relinkProject } from "@/lib/linking/engine";
import { resolveWorkArea } from "@/lib/work-areas/server";
import type { WorkAreaChoice } from "@/lib/work-areas/core";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
/**
@ -41,12 +43,14 @@ export async function createCard(input: {
title: string;
clientSlug: string;
projectSlug: string;
}): Promise<{ id: string }> {
} & WorkAreaChoice): 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");
if (!input.areaId && !input.newAreaName?.trim()) throw new Error("Choose an area");
const workArea = await resolveWorkArea(pb, input.projectId, input);
const last = await pb
.collection("cards")
@ -58,6 +62,7 @@ export async function createCard(input: {
const card = await pb.collection("cards").create<CardRecord>({
project: input.projectId,
column: input.columnId,
work_area: workArea,
title,
description: "",
position: positionBetween(last ? last.position : null, null),
@ -238,4 +243,4 @@ export async function unarchiveCard(input: {
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/archive`);
}
}

View file

@ -0,0 +1,57 @@
import { requireSession } from "@/lib/auth/session";
import { canReadClient } from "@/lib/auth/access";
import { getAdminPb } from "@/lib/pb/admin";
import { jsonError, jsonOk } from "@/lib/http/json";
import { listWorkAreas, resolveWorkArea } from "@/lib/work-areas/server";
import type { CardRecord, ProjectRecord } from "@/lib/pb/types";
/** The only editable card field here is its project-scoped area relation. */
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const session = await requireSession();
const { id } = await context.params;
const body: unknown = await request.json().catch(() => null);
if (!body || typeof body !== "object" || Array.isArray(body)) return jsonError("Invalid request body", 400);
const choice = body as Record<string, unknown>;
if ((choice.areaId !== undefined && typeof choice.areaId !== "string") ||
(choice.newAreaName !== undefined && typeof choice.newAreaName !== "string")) {
return jsonError("Invalid area", 400);
}
const pb = await getAdminPb();
const card = await pb.collection("cards").getOne<CardRecord>(id).catch(() => null);
if (!card) return jsonError("Card not found", 404);
const project = await pb.collection("projects").getOne<ProjectRecord>(card.project).catch(() => null);
if (!project || !canReadClient(session, project.client)) return jsonError("Card not found", 404);
const workArea = await resolveWorkArea(pb, project.id, {
areaId: choice.areaId as string | undefined,
newAreaName: choice.newAreaName as string | undefined,
}).catch(() => null);
if (workArea === null) return jsonError("Area not found in this project", 400);
if (card.work_area !== workArea) {
await pb.collection("cards").update(id, { work_area: workArea });
const areas = await listWorkAreas(pb, project.id);
await pb.collection("activity").create({
project: project.id,
card: id,
actor: session.userId,
action: "card_updated",
field: "work_area",
old_value: areas.find((area) => area.id === card.work_area)?.name ?? "",
new_value: areas.find((area) => area.id === workArea)?.name ?? "",
});
return jsonOk({ area: areas.find((area) => area.id === workArea) ?? null });
}
const areas = await listWorkAreas(pb, project.id);
return jsonOk({ area: areas.find((area) => area.id === workArea) ?? null });
} catch (error) {
console.error("[card-area] failed", error);
return jsonError("Could not save area", 500);
}
}

View file

@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PATCH } from "@/app/api/cards/[id]/area/route";
import { requireSession } from "@/lib/auth/session";
import { getAdminPb } from "@/lib/pb/admin";
import { resolveWorkArea } from "@/lib/work-areas/server";
vi.mock("@/lib/auth/session", () => ({ requireSession: vi.fn() }));
vi.mock("@/lib/pb/admin", () => ({ getAdminPb: vi.fn() }));
vi.mock("@/lib/work-areas/server", () => ({ resolveWorkArea: vi.fn(), listWorkAreas: vi.fn() }));
describe("card area route", () => {
const update = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ userId: "client-user", role: "client_manager", clientId: "client-a" });
vi.mocked(getAdminPb).mockResolvedValue({
collection: (name: string) => ({
getOne: async () => name === "cards"
? { id: "card-1", project: "project-b", work_area: "" }
: { id: "project-b", client: "client-b" },
update,
}),
} as never);
});
it("cannot assign a card belonging to another client, even with a known card id", async () => {
const response = await PATCH(new Request("http://localhost/api/cards/card-1/area", {
method: "PATCH", body: JSON.stringify({ newAreaName: "Delivery Notes" }),
}), { params: Promise.resolve({ id: "card-1" }) });
expect(response.status).toBe(404);
expect(update).not.toHaveBeenCalled();
expect(resolveWorkArea).not.toHaveBeenCalled();
});
});

View file

@ -9,6 +9,7 @@ import { pickClientCardCreate } from "@/lib/cards/client-patch";
import { nextCardNumber } from "@/lib/cards/number";
import { positionBetween } from "@/lib/ordering/fractional";
import { jsonError, jsonOk } from "@/lib/http/json";
import { resolveWorkArea } from "@/lib/work-areas/server";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
/**
@ -67,6 +68,8 @@ export async function POST(request: Request): Promise<Response> {
if (!input) {
return jsonError("A title is required", 400);
}
const workArea = await resolveWorkArea(pb, projectId, input).catch(() => null);
if (workArea === null) return jsonError("Area not found in this project", 400);
const inbox = await pb
.collection("columns")
@ -98,6 +101,7 @@ export async function POST(request: Request): Promise<Response> {
const card = await pb.collection("cards").create<CardRecord>({
project: projectId,
column: inbox.id,
work_area: workArea,
title: input.title,
description: input.description,
position: positionBetween(last ? last.position : null, null),
@ -127,4 +131,4 @@ export async function POST(request: Request): Promise<Response> {
console.error("[client-create] unexpected error", error);
return jsonError("Something went wrong", 500);
}
}
}

View file

@ -10,6 +10,7 @@ import { nextCardNumber } from "@/lib/cards/number";
import { parseBrief } from "@/lib/intake/brief";
import { sanitiseTurns } from "@/lib/intake/turn";
import { jsonError, jsonOk } from "@/lib/http/json";
import { resolveWorkArea } from "@/lib/work-areas/server";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
/**
@ -61,7 +62,6 @@ export async function POST(request: Request): Promise<Response> {
if (!canWriteClientFields(session, project.client)) {
return jsonError("Project not found", 404);
}
// Re-parsed, not trusted. The browser could send anything here.
const turns = sanitiseTurns(record.transcript as never);
const brief = parseBrief(
@ -83,6 +83,12 @@ export async function POST(request: Request): Promise<Response> {
return jsonError("This project cannot accept new cards", 409);
}
const workArea = await resolveWorkArea(pb, projectId, {
areaId: record.areaId as string | undefined,
newAreaName: record.newAreaName as string | undefined,
}).catch(() => null);
if (workArea === null) return jsonError("Area not found in this project", 400);
const last = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${inbox.id}"`, { sort: "-position" })
@ -97,6 +103,7 @@ export async function POST(request: Request): Promise<Response> {
project: projectId,
// Every field below is chosen here, not taken from the request.
column: inbox.id,
work_area: workArea,
title: brief.title,
description: brief.summary,
position: positionBetween(last ? last.position : null, null),

View file

@ -12,6 +12,7 @@ import {
} from "@/lib/intake-links";
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
import { jsonError, jsonOk } from "@/lib/http/json";
import { resolveWorkArea } from "@/lib/work-areas/server";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
/**
@ -109,6 +110,12 @@ export async function POST(request: Request): Promise<Response> {
return jsonError("This project cannot accept new cards", 409);
}
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
areaId: record.areaId as string | undefined,
newAreaName: record.newAreaName as string | undefined,
}).catch(() => null);
if (workArea === null) return jsonError("Area not found in this project", 400);
const last = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${inbox.id}"`, {
@ -125,6 +132,7 @@ export async function POST(request: Request): Promise<Response> {
project: resolvedProjectId,
// Every field below is chosen here, not taken from the request.
column: inbox.id,
work_area: workArea,
title: brief.title,
description: brief.summary,
position: positionBetween(last ? last.position : null, null),

View file

@ -4,7 +4,7 @@ import { getAdminPb } from "@/lib/pb/admin";
import { IntakePublic } from "@/components/intake/intake-public";
import { resolveIntakeLink } from "@/lib/intake-links";
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
import type { ProjectRecord } from "@/lib/pb/types";
import type { ProjectRecord, WorkAreaRecord } from "@/lib/pb/types";
/**
* The wizard for a Phase 9 client-bound link.
@ -53,6 +53,9 @@ export default async function IntakeWizardPage({
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(resolvedProjectId);
const areas = await pb.collection("work_areas").getFullList<WorkAreaRecord>({
filter: pb.filter("project = {:project}", { project: project.id }), sort: "created,name",
});
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
@ -65,6 +68,7 @@ export default async function IntakeWizardPage({
projectId={project.id}
projectName={project.name}
token={token}
areas={areas}
/>
</main>
);

View file

@ -14,6 +14,7 @@ import { CommentThread } from "@/components/card/comment-thread";
import { ActivityTimeline } from "@/components/card/activity-timeline";
import { Pill } from "@/components/ui/pill";
import { CardSubmittedBy } from "@/components/card/card-submitted-by";
import { CardAreaSection } from "@/components/work-areas/card-area-section";
import { getSession } from "@/lib/auth/session";
import { loadCardDetail } from "@/lib/data/card";
@ -70,6 +71,8 @@ export default async function CardPage({
projectSlug={projectSlug}
/>
<CardAreaSection cardId={detail.card.id} areaId={detail.card.work_area || ""} areas={detail.areas} />
<CardBrief
brief={detail.card.brief}
transcript={detail.card.transcript}
@ -101,4 +104,4 @@ export default async function CardPage({
</div>
</AppShell>
);
}
}

View file

@ -58,6 +58,7 @@ export default async function BoardPage({
projectSlug={projectSlug}
columns={board.columns}
initialCards={board.cards}
areas={board.areas}
// Pass the AI availability through so the client manager's Inbox shows
// the wizard when configured, the plain box when not. aiConfigured
// returns only whether a key exists — never the key itself.
@ -65,4 +66,4 @@ export default async function BoardPage({
/>
</AppShell>
);
}
}

View file

@ -77,6 +77,8 @@
.plan-timeline-table { min-width: 100%; }
.plan-timeline-header, .plan-timeline-row { display: flex; }
.plan-timeline-header { height: 67px; border-bottom: 1px solid var(--border-strong); background: var(--surface); }
.plan-area-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 35px; padding: 5px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--surface); }
.plan-area-legend > span:first-child { margin-right: 3px; color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: .11em; }
.plan-sticky-label { position: sticky; left: 0; z-index: 4; flex: none; width: 300px; border-right: 1px solid var(--border-subtle); background: var(--panel); }
.plan-label-heading { display: flex; align-items: flex-end; justify-content: space-between; padding: 0 17px 16px; background: var(--surface); color: var(--text-muted); font-size: 10px; font-weight: 800; letter-spacing: .1em; }
.plan-label-heading span { font-size: 9px; font-weight: 600; letter-spacing: 0; }
@ -101,6 +103,9 @@
.plan-card-title { min-width: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 700; line-height: 1.35; }
.plan-card-subline { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; }
.plan-card-subline span + span::before { content: "·"; margin-right: 8px; color: var(--border-strong); }
.plan-card-subline .plan-row-area { display: inline-flex; align-items: center; gap: 4px; max-width: 190px; overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.plan-card-subline .plan-row-area i { width: 7px; height: 7px; flex: none; border-radius: 2px; }
.plan-card-subline .plan-row-area + span::before { content: ""; margin: 0; }
.plan-card-subline .plan-needs-change { color: var(--accent); font-weight: 800; }
.plan-row-edit { position: absolute; right: 11px; top: 50%; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; transform: translateY(-50%); }
.plan-row-edit:hover { background: var(--surface-raised); color: var(--brand); opacity: 1; }

View file

@ -5,7 +5,9 @@ import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { createCard } from "@/app/actions/cards";
import type { Role } from "@/lib/pb/types";
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
import type { Role, WorkAreaRecord } from "@/lib/pb/types";
export function AddCard({
role,
@ -13,16 +15,19 @@ export function AddCard({
columnId,
clientSlug,
projectSlug,
areas,
}: {
role: Role;
projectId: string;
columnId: string;
clientSlug: string;
projectSlug: string;
areas: WorkAreaRecord[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
@ -32,22 +37,31 @@ export function AddCard({
async function submit(event: { preventDefault: () => void }) {
event.preventDefault();
const value = title.trim();
if (!value) return;
if (!value || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))) {
setError("Add a title and choose an area");
return;
}
setError(null);
if (role === "owner") {
setTitle("");
setOpen(false);
startTransition(() =>
createCard({
startTransition(async () => {
try {
await createCard({
projectId,
columnId,
title: value,
clientSlug,
projectSlug,
}).then(() => undefined)
);
...areaChoice,
});
setTitle("");
setAreaChoice({});
setOpen(false);
} catch {
setError("Could not add that. Please try again.");
}
});
return;
}
@ -56,7 +70,7 @@ export function AddCard({
const response = await fetch("/api/client/cards", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: projectId, title: value }),
body: JSON.stringify({ project: projectId, title: value, ...areaChoice }),
});
if (!response.ok) {
@ -65,6 +79,7 @@ export function AddCard({
}
setTitle("");
setAreaChoice({});
setOpen(false);
router.refresh();
}
@ -105,6 +120,10 @@ export function AddCard({
className="w-full resize-none bg-transparent text-[13px] leading-[1.45] text-text-primary outline-none placeholder:text-text-muted"
/>
<div className="py-2">
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={pending} />
</div>
{error && (
<p role="alert" className="px-0.5 pb-1 text-[11px] text-accent">
{error}
@ -114,7 +133,7 @@ export function AddCard({
<div className="flex items-center gap-2">
<button
type="submit"
disabled={pending || !title.trim()}
disabled={pending || !title.trim() || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
className="rounded-md bg-brand px-2.5 py-1 text-xs font-medium text-brand-fg disabled:opacity-50"
>
Add
@ -129,4 +148,4 @@ export function AddCard({
</div>
</form>
);
}
}

View file

@ -24,18 +24,22 @@ import { BoardColumn } from "@/components/board/board-column";
import { CardTile } from "@/components/board/card-tile";
import { ClientCardControls } from "@/components/board/client-card-controls";
import { RequestFeature } from "@/components/intake/request-feature";
import { AreaFilter } from "@/components/work-areas/area-filter";
import { workAreaForCard } from "@/lib/work-areas/core";
import { useRefreshOnFocus } from "@/components/board/use-refresh-on-focus";
import { computeDropPosition } from "@/lib/board/move";
import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types";
import type { CardRecord, ColumnRecord, Role, WorkAreaRecord } from "@/lib/pb/types";
function SortableCard({
card,
area,
role,
clientSlug,
projectSlug,
onPatched,
}: {
card: CardRecord;
area: WorkAreaRecord | null;
role: Role;
clientSlug: string;
projectSlug: string;
@ -49,6 +53,7 @@ function SortableCard({
<CardTile
ref={setNodeRef}
card={card}
area={area}
dragging={isDragging}
href={`/p/${clientSlug}/${projectSlug}/c/${card.id}`}
style={{ transform: CSS.Translate.toString(transform), transition }}
@ -93,6 +98,7 @@ export function Board({
projectSlug,
columns,
initialCards,
areas,
assistantAvailable,
}: {
role: Role;
@ -101,10 +107,12 @@ export function Board({
projectSlug: string;
columns: ColumnRecord[];
initialCards: CardRecord[];
areas: WorkAreaRecord[];
/** True when the AI provider is configured — controls the wizard vs plain box. */
assistantAvailable: boolean;
}) {
const [cards, setCards] = useState<CardRecord[]>(initialCards);
const [areaFilter, setAreaFilter] = useState("all");
const [activeId, setActiveId] = useState<string | null>(null);
/**
@ -161,9 +169,14 @@ export function Board({
const [stale, setStale] = useState(true);
useEffect(() => {
// Adopt the server snapshot on first mount, before any drag has touched
// local state. After that, the user's edits are authoritative until the
// component unmounts.
// local state. After a drag, keep moved cards authoritative but still
// admit newly created cards from a refreshed server snapshot.
if (stale) setCards(initialCards);
else setCards((current) => {
const known = new Set(current.map((card) => card.id));
const added = initialCards.filter((card) => !known.has(card.id));
return added.length ? [...current, ...added] : current;
});
}, [initialCards, stale]);
function onDragStart(event: DragStartEvent) {
@ -251,9 +264,11 @@ export function Board({
}
const content = (
<div className="flex h-full gap-3 overflow-x-auto p-6">
<div className="flex h-full min-h-0 flex-col">
<AreaFilter areas={areas} cards={cards} value={areaFilter} onChange={setAreaFilter} />
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-6">
{columns.map((column) => {
const list = byColumn.get(column.id) ?? [];
const list = (byColumn.get(column.id) ?? []).filter((card) => areaFilter === "all" || (areaFilter === "unassigned" ? !workAreaForCard(card, areas) : card.work_area === areaFilter));
return (
<Droppable key={column.id} column={column} count={list.length}>
<SortableContext
@ -264,6 +279,7 @@ export function Board({
<SortableCard
key={card.id}
card={card}
area={workAreaForCard(card, areas)}
role={role}
clientSlug={clientSlug}
projectSlug={projectSlug}
@ -278,6 +294,7 @@ export function Board({
columnId={column.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
areas={areas}
/>
)}
@ -288,11 +305,13 @@ export function Board({
clientSlug={clientSlug}
projectSlug={projectSlug}
assistantAvailable={assistantAvailable}
areas={areas}
/>
)}
</Droppable>
);
})}
</div>
</div>
);
@ -319,9 +338,9 @@ export function Board({
>
{content}
<DragOverlay>
{activeCard && <CardTile card={activeCard} dragging />}
{activeCard && <CardTile card={activeCard} area={workAreaForCard(activeCard, areas)} dragging />}
</DragOverlay>
</DndContext>
</div>
);
}
}

View file

@ -6,8 +6,9 @@ import { Inbox, CalendarDays, Maximize2 } from "lucide-react";
import { Pill } from "@/components/ui/pill";
import { cn } from "@/lib/cn";
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
import type { CardRecord, Priority } from "@/lib/pb/types";
import type { CardRecord, Priority, WorkAreaRecord } from "@/lib/pb/types";
const PRIORITY_TONE: Record<Priority, "critical" | "high" | "medium" | "low"> = {
critical: "critical",
@ -27,12 +28,13 @@ export const CardTile = forwardRef<
HTMLDivElement,
{
card: CardRecord;
area?: Pick<WorkAreaRecord, "name" | "color"> | null;
dragging?: boolean;
href?: string;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(function CardTile({ card, dragging, href, className, children, ...props }, ref) {
>(function CardTile({ card, area, dragging, href, className, children, style, ...props }, ref) {
const due = classifyDueDate(card.target_date);
const dueLabel = formatDueDate(card.target_date);
@ -45,6 +47,7 @@ export const CardTile = forwardRef<
dragging && "shadow-[var(--shadow-drag)] opacity-90",
className
)}
style={{ ...(area ? { borderLeftColor: area.color, borderLeftWidth: 3 } : {}), ...style }}
{...props}
>
<div className="flex items-start gap-2">
@ -66,6 +69,7 @@ export const CardTile = forwardRef<
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{area && <WorkAreaBadge area={area} compact />}
<Pill tone={PRIORITY_TONE[card.priority]}>
{PRIORITY_LABEL[card.priority]}
</Pill>
@ -90,4 +94,4 @@ export const CardTile = forwardRef<
{children}
</div>
);
});
});

View file

@ -4,6 +4,9 @@ import { useState } from "react";
import { Send } from "lucide-react";
import { Pill } from "@/components/ui/pill";
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
import type { Brief } from "@/lib/intake/brief";
import type { SizeBand } from "@/lib/intake/size";
import type { Turn } from "@/lib/intake/turn";
@ -29,15 +32,24 @@ export function BriefPreviewPublic({
brief,
turns,
token,
projectId,
areas,
}: {
brief: Brief & { sizeBand: SizeBand };
turns: Turn[];
token: string;
projectId: string;
areas: WorkAreaRecord[];
}) {
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
async function submit() {
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
setError("Choose an area for this request.");
return;
}
setSending(true);
setError(null);
@ -46,9 +58,11 @@ export function BriefPreviewPublic({
headers: { "content-type": "application/json" },
body: JSON.stringify({
token,
projectId,
brief,
transcript: turns,
sizeBand: brief.sizeBand,
...areaChoice,
}),
});
@ -112,10 +126,12 @@ export function BriefPreviewPublic({
</div>
)}
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={sending} />
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
<button
type="button"
disabled={sending}
disabled={sending || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
onClick={submit}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>

View file

@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
import { Send } from "lucide-react";
import { Pill } from "@/components/ui/pill";
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
import type { Brief } from "@/lib/intake/brief";
import type { SizeBand } from "@/lib/intake/size";
import type { Turn } from "@/lib/intake/turn";
@ -28,18 +31,25 @@ export function BriefPreview({
projectId,
clientSlug,
projectSlug,
areas,
}: {
brief: Brief & { sizeBand: SizeBand };
turns: Turn[];
projectId: string;
clientSlug: string;
projectSlug: string;
areas: WorkAreaRecord[];
}) {
const router = useRouter();
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
async function submit() {
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
setError("Choose an area for this request.");
return;
}
setSending(true);
setError(null);
@ -51,6 +61,7 @@ export function BriefPreview({
brief,
transcript: turns,
sizeBand: brief.sizeBand,
...areaChoice,
}),
});
@ -114,10 +125,12 @@ export function BriefPreview({
</div>
)}
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={sending} />
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
<button
type="button"
disabled={sending}
disabled={sending || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
onClick={submit}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>

View file

@ -6,6 +6,7 @@ import { Sparkles, ArrowRight } from "lucide-react";
import { BriefPreviewPublic } from "@/components/intake/brief-preview-public";
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
import type { InterviewStep } from "@/lib/intake/interview";
import type { WorkAreaRecord } from "@/lib/pb/types";
/**
* The public route's interview flow.
@ -20,10 +21,12 @@ export function IntakePublic({
projectId,
projectName,
token,
areas,
}: {
projectId: string;
projectName: string;
token: string;
areas: WorkAreaRecord[];
}) {
const [turns, setTurns] = useState<Turn[]>([]);
const [question, setQuestion] = useState<string | null>(null);
@ -59,6 +62,8 @@ export function IntakePublic({
brief={step.brief}
turns={turns}
token={token}
projectId={projectId}
areas={areas}
/>
);
}

View file

@ -7,18 +7,21 @@ import { intakeStepAction } from "@/app/actions/intake";
import { BriefPreview } from "@/components/intake/brief-preview";
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
import type { InterviewStep } from "@/lib/intake/interview";
import type { WorkAreaRecord } from "@/lib/pb/types";
export function IntakeWizard({
projectId,
clientSlug,
projectSlug,
onFallback,
areas,
}: {
projectId: string;
clientSlug: string;
projectSlug: string;
/** Shown when the assistant is unavailable — the plain request box. */
onFallback: () => void;
areas: WorkAreaRecord[];
}) {
const [turns, setTurns] = useState<Turn[]>([]);
const [question, setQuestion] = useState<string | null>(null);
@ -47,6 +50,7 @@ export function IntakeWizard({
projectId={projectId}
clientSlug={clientSlug}
projectSlug={projectSlug}
areas={areas}
/>
);
}

View file

@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
import { AddCard } from "@/components/board/add-card";
import { IntakeWizard } from "@/components/intake/intake-wizard";
import type { WorkAreaRecord } from "@/lib/pb/types";
/**
* The owner's two ways to add work.
@ -27,12 +28,14 @@ export function OwnerAdd({
clientSlug,
projectSlug,
assistantAvailable,
areas,
}: {
projectId: string;
columnId: string;
clientSlug: string;
projectSlug: string;
assistantAvailable: boolean;
areas: WorkAreaRecord[];
}) {
const [planning, setPlanning] = useState(false);
@ -44,6 +47,7 @@ export function OwnerAdd({
clientSlug={clientSlug}
projectSlug={projectSlug}
onFallback={() => setPlanning(false)}
areas={areas}
/>
<button
type="button"
@ -64,6 +68,7 @@ export function OwnerAdd({
columnId={columnId}
clientSlug={clientSlug}
projectSlug={projectSlug}
areas={areas}
/>
{assistantAvailable && (

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { IntakeWizard } from "@/components/intake/intake-wizard";
import { AddCard } from "@/components/board/add-card";
import type { WorkAreaRecord } from "@/lib/pb/types";
/**
* The client's way in: the wizard when the assistant is available, the plain
@ -18,12 +19,14 @@ export function RequestFeature({
clientSlug,
projectSlug,
assistantAvailable,
areas,
}: {
projectId: string;
columnId: string;
clientSlug: string;
projectSlug: string;
assistantAvailable: boolean;
areas: WorkAreaRecord[];
}) {
// Flips if the interview reports the provider unavailable mid-flow.
const [fallback, setFallback] = useState(!assistantAvailable);
@ -36,6 +39,7 @@ export function RequestFeature({
columnId={columnId}
clientSlug={clientSlug}
projectSlug={projectSlug}
areas={areas}
/>
);
}
@ -46,6 +50,7 @@ export function RequestFeature({
clientSlug={clientSlug}
projectSlug={projectSlug}
onFallback={() => setFallback(true)}
areas={areas}
/>
);
}

View file

@ -22,6 +22,7 @@ import {
} from "@/app/actions/project-plan";
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
import { WorkItemDialog } from "@/components/planning/work-item-dialog";
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
import { useToast } from "@/components/ui/toast";
import {
calendarAxis,
@ -423,6 +424,14 @@ function ProjectTimeline({
)}
</div>
</div>
{project.areas.some((area) => project.cards.some((card) => card.area?.id === area.id)) && (
<div className="plan-area-legend" style={{ width: 300 + axis.width }} aria-label="Areas in this project">
<span>AREAS</span>
{project.areas.filter((area) => project.cards.some((card) => card.area?.id === area.id)).map((area) => (
<WorkAreaBadge key={area.id} area={area} />
))}
</div>
)}
{sortPlanningCards(project.cards, sortOrder).map((card) => (
<TimelineRow
key={card.id}
@ -494,13 +503,14 @@ function TimelineRow({
return (
<div className="plan-timeline-row" onDoubleClick={canEdit ? openDateEditorOnDoubleClick : undefined}>
<div className="plan-sticky-label plan-card-label">
<div className="plan-sticky-label plan-card-label" style={card.area ? { borderLeft: `3px solid ${card.area.color}`, paddingLeft: 14 } : undefined}>
<button type="button" className="plan-card-details-button" onClick={(event) => openDetailsAfterClick(event.detail)} aria-label={`View details for ${card.title}`}>
<span className="plan-card-name">
<span className={`plan-priority-dot is-${card.priority}`} />
<span className="plan-card-title">{card.title}</span>
</span>
<span className="plan-card-subline">
{card.area && <span className="plan-row-area"><i style={{ backgroundColor: card.area.color }} />{card.area.name}</span>}
<span>{card.columnName}</span>
{card.number > 0 && <span>#{card.number}</span>}
{progress !== null && <span>{progress}% complete</span>}

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { ArrowUpRight, CalendarDays, X } from "lucide-react";
import type { PlanningCard } from "@/lib/planning/load";
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
const priorityLabel: Record<PlanningCard["priority"], string> = {
critical: "Critical priority",
@ -79,6 +80,7 @@ export function WorkItemDialog({
<div className="plan-item-body">
<h2>{card.title}</h2>
<div className="plan-item-tags">
{card.area && <WorkAreaBadge area={card.area} />}
<span className={`plan-item-priority is-${card.priority}`}><i />{priorityLabel[card.priority]}</span>
<span>{card.columnName}</span>
</div>

View file

@ -0,0 +1,37 @@
"use client";
import { workAreaForCard } from "@/lib/work-areas/core";
import type { CardRecord, WorkAreaRecord } from "@/lib/pb/types";
export function AreaFilter({ areas, cards, value, onChange }: {
areas: WorkAreaRecord[];
cards: CardRecord[];
value: string;
onChange: (value: string) => void;
}) {
const uncategorised = cards.filter((card) => !workAreaForCard(card, areas)).length;
return (
<div className="flex flex-wrap items-center gap-1.5 border-b border-border-subtle bg-panel px-6 py-3" aria-label="Filter by area">
<span className="mr-2 text-[10px] font-bold uppercase tracking-[.13em] text-text-muted">Areas</span>
<button type="button" onClick={() => onChange("all")} aria-pressed={value === "all"} className={`rounded-md px-2 py-1 text-xs font-medium ${value === "all" ? "bg-brand-soft text-brand" : "text-text-secondary hover:bg-surface"}`}>
All <span className="ml-1 tabular-nums opacity-60">{cards.length}</span>
</button>
{areas.map((area) => {
const count = cards.filter((card) => card.work_area === area.id).length;
return (
<button key={area.id} type="button" onClick={() => onChange(area.id)} aria-pressed={value === area.id} className={`inline-flex max-w-44 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium ${value === area.id ? "border-border-strong bg-surface-raised text-text-primary" : "border-transparent text-text-secondary hover:bg-surface"}`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: area.color }} aria-hidden="true" />
<span className="truncate">{area.name}</span>
<span className="tabular-nums opacity-55">{count}</span>
</button>
);
})}
{uncategorised > 0 && (
<button type="button" onClick={() => onChange("unassigned")} aria-pressed={value === "unassigned"} className={`rounded-md px-2 py-1 text-xs ${value === "unassigned" ? "bg-surface-raised text-text-primary" : "text-text-muted hover:bg-surface"}`}>
Uncategorised <span className="ml-1 tabular-nums opacity-55">{uncategorised}</span>
</button>
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
export function CardAreaSection({ cardId, areaId, areas }: {
cardId: string;
areaId: string;
areas: WorkAreaRecord[];
}) {
const router = useRouter();
const [choice, setChoice] = useState<WorkAreaChoice>({ areaId });
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const current = areas.find((area) => area.id === areaId);
const canSave = choice.newAreaName !== undefined
? Boolean(cleanWorkAreaName(choice.newAreaName))
: (choice.areaId ?? "") !== areaId;
async function save() {
setSaving(true);
setError("");
try {
const response = await fetch(`/api/cards/${cardId}/area`, {
method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(choice),
});
if (!response.ok) throw new Error("Could not save the area");
const result = await response.json();
setChoice({ areaId: result.area?.id ?? "" });
router.refresh();
} catch {
setError("Could not save the area. Please try again.");
} finally {
setSaving(false);
}
}
return <section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">Area</h2>
{current && <WorkAreaBadge area={current} />}
</div>
<WorkAreaPicker areas={areas} value={choice} onChange={setChoice} disabled={saving} />
<button type="button" onClick={save} disabled={saving || !canSave} className="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-brand-fg disabled:opacity-45">{saving ? "Saving…" : "Save area"}</button>
{error && <p role="alert" className="text-xs text-accent">{error}</p>}
</section>;
}

View file

@ -0,0 +1,16 @@
import type { WorkAreaRecord } from "@/lib/pb/types";
export function WorkAreaBadge({ area, compact = false }: {
area: Pick<WorkAreaRecord, "name" | "color">;
compact?: boolean;
}) {
return (
<span
className={`inline-flex min-w-0 items-center gap-1.5 rounded-md border border-border-subtle bg-surface px-1.5 py-0.5 text-[10px] font-semibold text-text-secondary${compact ? " max-w-full" : ""}`}
title={area.name}
>
<span className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: area.color }} aria-hidden="true" />
<span className="truncate">{area.name}</span>
</span>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { useEffect, useId, useState } from "react";
import type { WorkAreaChoice } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
export function WorkAreaPicker({
areas,
value,
onChange,
disabled = false,
}: {
areas: Pick<WorkAreaRecord, "id" | "name" | "color">[];
value: WorkAreaChoice;
onChange: (value: WorkAreaChoice) => void;
disabled?: boolean;
}) {
const id = useId();
const [creating, setCreating] = useState(areas.length === 0 || Boolean(value.newAreaName));
useEffect(() => {
if (value.areaId) setCreating(false);
}, [value.areaId]);
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-[11px] font-semibold text-text-secondary">Area</label>
<select
id={id}
value={creating ? "new" : value.areaId || ""}
disabled={disabled}
onChange={(event) => {
const selected = event.target.value;
setCreating(selected === "new");
onChange(selected === "new" ? { newAreaName: "" } : { areaId: selected });
}}
className="h-9 w-full rounded-lg border border-border-strong bg-panel px-2.5 text-xs text-text-primary focus:border-brand focus:outline-none disabled:opacity-50"
>
<option value="">Choose an area</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
<option value="new">+ Create a new area</option>
</select>
{creating && (
<input
type="text"
maxLength={80}
value={value.newAreaName || ""}
disabled={disabled}
onChange={(event) => onChange({ newAreaName: event.target.value })}
placeholder="e.g. Delivery Notes"
aria-label="New area name"
className="h-9 w-full rounded-lg border border-border-strong bg-panel px-2.5 text-xs text-text-primary focus:border-brand focus:outline-none disabled:opacity-50"
/>
)}
<p className="text-[10px] leading-relaxed text-text-muted">Related cards share an area color. Each card keeps its own status and dates.</p>
</div>
);
}

View file

@ -62,6 +62,13 @@ describe("describeActivity", () => {
expect(describeActivity(row({ action: "card_archived" }))).toBe("archived this card");
});
it("names areas when a card is grouped or ungrouped", () => {
expect(describeActivity(row({ action: "card_updated", field: "work_area", new_value: "Delivery Notes" })))
.toBe("added this card to Delivery Notes");
expect(describeActivity(row({ action: "card_updated", field: "work_area", old_value: "Delivery Notes" })))
.toBe("removed this card from Delivery Notes");
});
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");
@ -164,4 +171,4 @@ describe("describeActivity", () => {
const result = describeActivity(row({ action: unknown }));
expect(result.length).toBeGreaterThan(0);
});
});
});

View file

@ -97,6 +97,11 @@ export function describeActivity(row: ActivityRecord): string {
case "card_updated":
if (row.field === "title") return "renamed this card";
if (row.field === "work_area") {
if (!row.new_value) return `removed this card from ${row.old_value}`;
if (!row.old_value) return `added this card to ${row.new_value}`;
return `moved this card from ${row.old_value} to ${row.new_value}`;
}
if (row.field) return `updated the ${row.field}`;
return "updated this card";
@ -105,4 +110,4 @@ export function describeActivity(row: ActivityRecord): string {
// a history the client reads is worse than a vague one.
return "made a change";
}
}
}

View file

@ -59,7 +59,7 @@ describe("pickClientCardPatch", () => {
});
describe("pickClientCardCreate", () => {
it("accepts title and description only", () => {
it("accepts title, description, and a project-scoped area choice only", () => {
const { input, dropped } = pickClientCardCreate({
title: "Add a POS system",
description: "We need card payments at the counter.",
@ -67,6 +67,8 @@ describe("pickClientCardCreate", () => {
expect(input).toEqual({
title: "Add a POS system",
description: "We need card payments at the counter.",
areaId: "",
newAreaName: "",
});
expect(dropped).toEqual([]);
});
@ -78,10 +80,18 @@ describe("pickClientCardCreate", () => {
accepted: true,
priority: "critical",
});
expect(input).toEqual({ title: "x", description: "" });
expect(input).toEqual({ title: "x", description: "", areaId: "", newAreaName: "" });
expect(dropped.sort()).toEqual(["accepted", "column", "priority"].sort());
});
it("accepts an area choice while refusing a client-supplied color", () => {
const { input, dropped } = pickClientCardCreate({
project: "project-1", title: "Improve search", newAreaName: "Delivery Notes", color: "#000000",
});
expect(input?.newAreaName).toBe("Delivery Notes");
expect(dropped).toEqual(["color"]);
});
it("rejects a missing or blank title", () => {
expect(pickClientCardCreate({ description: "no title" }).input).toBeNull();
expect(pickClientCardCreate({ title: " " }).input).toBeNull();
@ -91,4 +101,4 @@ describe("pickClientCardCreate", () => {
const { input } = pickClientCardCreate({ title: ` ${"a".repeat(250)} ` });
expect(input!.title).toHaveLength(200);
});
});
});

View file

@ -29,6 +29,8 @@ export interface ClientCardPatch {
export interface ClientCardCreate {
title: string;
description: string;
areaId: string;
newAreaName: string;
}
function asRecord(body: unknown): Record<string, unknown> | null {
@ -99,6 +101,8 @@ export function pickClientCardCreate(body: unknown): {
const dropped: string[] = [];
let title = "";
let description = "";
let areaId = "";
let newAreaName = "";
for (const [key, value] of Object.entries(record)) {
if (key === "title") {
@ -113,6 +117,18 @@ export function pickClientCardCreate(body: unknown): {
continue;
}
if (key === "areaId") {
if (typeof value === "string") areaId = value;
else dropped.push(key);
continue;
}
if (key === "newAreaName") {
if (typeof value === "string") newAreaName = value;
else dropped.push(key);
continue;
}
// `project` is read from the body by the route handler before calling
// this, so it is expected here and is not a violation.
if (key === "project") continue;
@ -121,5 +137,5 @@ export function pickClientCardCreate(body: unknown): {
}
if (!title) return { input: null, dropped };
return { input: { title, description }, dropped };
}
return { input: { title, description, areaId, newAreaName }, dropped };
}

View file

@ -6,6 +6,7 @@ import type {
ClientRecord,
ColumnRecord,
ProjectRecord,
WorkAreaRecord,
} from "@/lib/pb/types";
export interface BoardData {
@ -13,6 +14,7 @@ export interface BoardData {
project: ProjectRecord;
columns: ColumnRecord[];
cards: CardRecord[];
areas: WorkAreaRecord[];
}
export interface ProjectSummary {
@ -60,12 +62,16 @@ export async function loadBoard(
sort: "position",
});
const cards = await pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${project.id}" && archived = false`,
sort: "position",
});
const [cards, areas] = await Promise.all([
pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${project.id}" && archived = false`, sort: "position",
}),
pb.collection("work_areas").getFullList<WorkAreaRecord>({
filter: `project = "${project.id}"`, sort: "created,name",
}),
]);
return { client, project, columns, cards };
return { client, project, columns, cards, areas };
} catch {
return null;
}

View file

@ -11,6 +11,7 @@ import type {
ProjectRecord,
SubtaskRecord,
UserRecord,
WorkAreaRecord,
} from "@/lib/pb/types";
export interface CardDetail {
@ -19,6 +20,7 @@ export interface CardDetail {
project: ProjectRecord;
client: ClientRecord;
columns: ColumnRecord[];
areas: WorkAreaRecord[];
subtasks: SubtaskRecord[];
comments: CommentRecord[];
activity: ActivityRecord[];
@ -70,6 +72,10 @@ export async function loadCardDetail(
sort: "position",
});
const areas = await pb.collection("work_areas").getFullList<WorkAreaRecord>({
filter: `project = "${project.id}"`, sort: "created,name",
});
const column = columns.find((c) => c.id === card.column);
if (!column) return null;
@ -115,6 +121,7 @@ export async function loadCardDetail(
project,
client,
columns,
areas,
subtasks,
comments,
activity,
@ -124,4 +131,4 @@ export async function loadCardDetail(
} catch {
return null;
}
}
}

View file

@ -78,9 +78,17 @@ export interface ColumnRecord extends BaseRecord {
is_done: boolean;
}
export interface WorkAreaRecord extends BaseRecord {
project: string;
name: string;
color: string;
}
export interface CardRecord extends BaseRecord {
project: string;
column: string;
/** Optional project-scoped grouping, independent of the Kanban column. */
work_area: string;
title: string;
description: string;
position: number;

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildPlanningBoard } from "@/lib/planning/load";
import type { CardRecord, ClientRecord, ColumnRecord, ProjectRecord, SubtaskRecord } from "@/lib/pb/types";
import type { CardRecord, ClientRecord, ColumnRecord, ProjectRecord, SubtaskRecord, WorkAreaRecord } from "@/lib/pb/types";
const client = { id: "client", name: "Acme", slug: "acme", brand_color: "#168eaa" } as ClientRecord;
const project = { id: "project", client: "client", name: "Website", slug: "website", status: "active" } as ProjectRecord;
@ -36,10 +36,26 @@ describe("planning board loader", () => {
card("six", "done", 6),
],
subtasks: [] as SubtaskRecord[],
areas: [],
});
expect(board).toHaveLength(1);
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
expect(board[0].cards[0].description).toBe("The full client request with details.");
});
it("shows the project's area on each linked card without importing another project's area", () => {
const delivery = { id: "delivery", project: "project", name: "Delivery Notes", color: "#3189aa" } as WorkAreaRecord;
const foreign = { id: "foreign", project: "other", name: "Private", color: "#ad668a" } as WorkAreaRecord;
const board = buildPlanningBoard({
clients: [client], projects: [project], columns: [column("todo", "To Do")],
cards: [
{ ...card("one", "todo", 1), work_area: delivery.id },
{ ...card("two", "todo", 2), work_area: foreign.id },
],
subtasks: [], areas: [delivery, foreign],
});
expect(board[0].areas).toEqual([delivery]);
expect(board[0].cards.map((item) => item.area?.name ?? null)).toEqual(["Delivery Notes", null]);
});
});

View file

@ -9,6 +9,7 @@ import type {
ColumnRecord,
ProjectRecord,
SubtaskRecord,
WorkAreaRecord,
} from "@/lib/pb/types";
export interface PlanningCard {
@ -25,6 +26,7 @@ export interface PlanningCard {
subtaskDone: number;
subtaskTotal: number;
href: string;
area: Pick<WorkAreaRecord, "id" | "name" | "color"> | null;
}
export interface PlanningProject {
@ -34,6 +36,7 @@ export interface PlanningProject {
clientColor: string;
plan: ProjectPlan;
cards: PlanningCard[];
areas: WorkAreaRecord[];
}
interface PlanningRows {
@ -42,6 +45,7 @@ interface PlanningRows {
columns: ColumnRecord[];
cards: CardRecord[];
subtasks: SubtaskRecord[];
areas: WorkAreaRecord[];
}
/** Review and Done have left the planning queue. */
@ -56,9 +60,11 @@ export function buildPlanningBoard({
columns,
cards,
subtasks,
areas,
}: PlanningRows): PlanningProject[] {
const clientsById = new Map(clients.map((client) => [client.id, client]));
const columnsById = new Map(columns.map((column) => [column.id, column]));
const areasById = new Map(areas.map((area) => [area.id, area]));
const counts = new Map<string, { done: number; total: number }>();
for (const subtask of subtasks) {
const count = counts.get(subtask.card) ?? { done: 0, total: 0 };
@ -84,6 +90,7 @@ export function buildPlanningBoard({
const plan = readProjectPlan(project.plan_data);
const projectCards = (cardsByProject.get(project.id) ?? []).map<PlanningCard>((card) => {
const count = counts.get(card.id) ?? { done: 0, total: 0 };
const area = areasById.get(card.work_area);
return {
id: card.id,
created: card.created,
@ -98,6 +105,7 @@ export function buildPlanningBoard({
subtaskDone: count.done,
subtaskTotal: count.total,
href: `/p/${client.slug}/${project.slug}/c/${card.id}`,
area: area?.project === project.id ? area : null,
};
});
if (projectCards.length === 0) continue;
@ -108,6 +116,7 @@ export function buildPlanningBoard({
clientColor: client.brand_color || "var(--brand)",
plan,
cards: sortPlanningCards(projectCards, "date"),
areas: areas.filter((area) => area.project === project.id),
});
}
return output.sort((a, b) =>
@ -119,12 +128,13 @@ export function buildPlanningBoard({
export async function loadPlanningBoard(): Promise<PlanningProject[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const [clients, projects, columns, cards, subtasks] = await Promise.all([
const [clients, projects, columns, cards, subtasks, areas] = await Promise.all([
pb.collection("clients").getFullList<ClientRecord>(),
pb.collection("projects").getFullList<ProjectRecord>({ filter: 'status = "active"' }),
pb.collection("columns").getFullList<ColumnRecord>(),
pb.collection("cards").getFullList<CardRecord>({ filter: "archived = false" }),
pb.collection("subtasks").getFullList<SubtaskRecord>({ fields: "card,done" }),
pb.collection("work_areas").getFullList<WorkAreaRecord>(),
]);
return buildPlanningBoard({ clients, projects, columns, cards, subtasks });
return buildPlanningBoard({ clients, projects, columns, cards, subtasks, areas });
}

View file

@ -18,6 +18,7 @@ function card(id: string, number: number, priority: PlanningCard["priority"], st
subtaskDone: 0,
subtaskTotal: 0,
href: `/cards/${id}`,
area: null,
};
}

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { cleanWorkAreaName, nextWorkAreaColor, WORK_AREA_COLORS, workAreaForCard } from "@/lib/work-areas/core";
describe("work areas", () => {
it("normalizes names and reuses only distinct saved colors until the palette is full", () => {
expect(cleanWorkAreaName(" Delivery Notes \n")).toBe("Delivery Notes");
expect(cleanWorkAreaName(12)).toBe("");
expect(nextWorkAreaColor([{ color: WORK_AREA_COLORS[0] }])).toBe(WORK_AREA_COLORS[1]);
expect(nextWorkAreaColor(WORK_AREA_COLORS.map((color) => ({ color })))).toBe(WORK_AREA_COLORS[0]);
});
it("finds only a card's linked area", () => {
const areas = [{ id: "delivery", name: "Delivery Notes" }];
expect(workAreaForCard({ work_area: "delivery" }, areas)).toEqual(areas[0]);
expect(workAreaForCard({ work_area: "other" }, areas)).toBeNull();
});
});

View file

@ -0,0 +1,30 @@
import type { WorkAreaRecord } from "@/lib/pb/types";
export interface WorkAreaChoice {
areaId?: string;
newAreaName?: string;
}
/** Distinct accents on both light and dark surfaces. Text always accompanies color. */
export const WORK_AREA_COLORS = [
"#3189aa", "#ad668a", "#ae7938", "#4f9569",
"#696bb5", "#bb655b", "#829145", "#597eaa",
] as const;
export function cleanWorkAreaName(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim().replace(/\s+/g, " ").slice(0, 80);
}
export function nextWorkAreaColor(areas: Pick<WorkAreaRecord, "color">[]): string {
const used = new Set(areas.map((area) => area.color.toLowerCase()));
return WORK_AREA_COLORS.find((color) => !used.has(color))
?? WORK_AREA_COLORS[areas.length % WORK_AREA_COLORS.length];
}
export function workAreaForCard<T extends Pick<WorkAreaRecord, "id">>(
card: { work_area?: string },
areas: T[]
): T | null {
return areas.find((area) => area.id === card.work_area) ?? null;
}

View file

@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { resolveWorkArea } from "@/lib/work-areas/server";
import { WORK_AREA_COLORS } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
describe("resolveWorkArea", () => {
it("rejects an area from another project and reuses a matching name", async () => {
const rows = [
{ id: "delivery", project: "p1", name: "Delivery Notes", color: WORK_AREA_COLORS[0] },
{ id: "foreign", project: "p2", name: "Vehicles", color: WORK_AREA_COLORS[0] },
] as WorkAreaRecord[];
const create = vi.fn();
const pb = {
filter: (_template: string, values: { project: string }) => values.project,
collection: () => ({
getFullList: ({ filter }: { filter: string }) => Promise.resolve(rows.filter((row) => row.project === filter)),
create,
}),
};
await expect(resolveWorkArea(pb as never, "p1", { areaId: "foreign" })).rejects.toThrow("Area not found");
expect(await resolveWorkArea(pb as never, "p1", { newAreaName: " delivery notes " })).toBe("delivery");
expect(create).not.toHaveBeenCalled();
await expect(resolveWorkArea(pb as never, "p1", { areaId: "delivery", newAreaName: "Other" })).rejects.toThrow();
});
it("assigns a distinct saved color to a new area", async () => {
const create = vi.fn(async (body: { color: string }) => ({ id: "new", ...body }));
const pb = {
filter: () => "p1",
collection: () => ({
getFullList: async () => [{ id: "existing", project: "p1", name: "Search", color: WORK_AREA_COLORS[0] }],
create,
}),
};
expect(await resolveWorkArea(pb as never, "p1", { newAreaName: "Purchase Orders" })).toBe("new");
expect(create).toHaveBeenCalledWith({ project: "p1", name: "Purchase Orders", color: WORK_AREA_COLORS[1] });
});
});

View file

@ -0,0 +1,48 @@
import "server-only";
import type PocketBase from "pocketbase";
import { cleanWorkAreaName, nextWorkAreaColor, type WorkAreaChoice } from "@/lib/work-areas/core";
import type { WorkAreaRecord } from "@/lib/pb/types";
export async function listWorkAreas(pb: PocketBase, projectId: string): Promise<WorkAreaRecord[]> {
return pb.collection("work_areas").getFullList<WorkAreaRecord>({
filter: pb.filter("project = {:project}", { project: projectId }),
sort: "created,name",
});
}
/** Resolve only within the authorized project; never trust a submitted area id or color. */
export async function resolveWorkArea(
pb: PocketBase,
projectId: string,
choice: WorkAreaChoice
): Promise<string> {
const areaId = typeof choice.areaId === "string" ? choice.areaId : "";
const newName = cleanWorkAreaName(choice.newAreaName);
if (areaId && newName) throw new Error("Choose an existing area or create a new one");
if (!areaId && !newName) return "";
const areas = await listWorkAreas(pb, projectId);
if (areaId) {
if (!areas.some((area) => area.id === areaId)) throw new Error("Area not found in this project");
return areaId;
}
const existing = areas.find((area) => area.name.toLocaleLowerCase() === newName.toLocaleLowerCase());
if (existing) return existing.id;
try {
const created = await pb.collection("work_areas").create<WorkAreaRecord>({
project: projectId,
name: newName,
color: nextWorkAreaColor(areas),
});
return created.id;
} catch (error) {
// A second request may have created the same name after our read.
const match = (await listWorkAreas(pb, projectId))
.find((area) => area.name.toLocaleLowerCase() === newName.toLocaleLowerCase());
if (match) return match.id;
throw error;
}
}