From 20639264bdea56d984a2e4b9ae3244eef2e94e02 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 21:46:35 +0200 Subject: [PATCH] Add project work areas to Kanban and Plan --- package.json | 1 + scripts/apply-work-area-schema.ts | 20 +++++++ scripts/setup-remote.ts | 2 + scripts/work-area-schema.ts | 41 +++++++++++++ src/app/actions/cards.ts | 9 ++- src/app/api/cards/[id]/area/route.ts | 57 ++++++++++++++++++ src/app/api/cards/area-route.test.ts | 37 ++++++++++++ src/app/api/client/cards/route.ts | 6 +- src/app/api/client/intake/route.ts | 9 ++- src/app/api/public/intake/route.ts | 8 +++ src/app/intake/[token]/p/[projectId]/page.tsx | 6 +- .../[projectSlug]/c/[cardId]/page.tsx | 5 +- src/app/p/[clientSlug]/[projectSlug]/page.tsx | 3 +- src/app/plan/plan.css | 5 ++ src/components/board/add-card.tsx | 41 +++++++++---- src/components/board/board.tsx | 33 ++++++++--- src/components/board/card-tile.tsx | 10 +++- .../intake/brief-preview-public.tsx | 18 +++++- src/components/intake/brief-preview.tsx | 15 ++++- src/components/intake/intake-public.tsx | 5 ++ src/components/intake/intake-wizard.tsx | 4 ++ src/components/intake/owner-add.tsx | 5 ++ src/components/intake/request-feature.tsx | 5 ++ src/components/planning/plan-board.tsx | 12 +++- src/components/planning/work-item-dialog.tsx | 2 + src/components/work-areas/area-filter.tsx | 37 ++++++++++++ .../work-areas/card-area-section.tsx | 52 +++++++++++++++++ src/components/work-areas/work-area-badge.tsx | 16 +++++ .../work-areas/work-area-picker.tsx | 58 +++++++++++++++++++ src/lib/activity/format.test.ts | 9 ++- src/lib/activity/format.ts | 7 ++- src/lib/cards/client-patch.test.ts | 16 ++++- src/lib/cards/client-patch.ts | 20 ++++++- src/lib/data/board.ts | 16 +++-- src/lib/data/card.ts | 9 ++- src/lib/pb/types.ts | 8 +++ src/lib/planning/load.test.ts | 18 +++++- src/lib/planning/load.ts | 14 ++++- src/lib/planning/sort.test.ts | 1 + src/lib/work-areas/core.test.ts | 18 ++++++ src/lib/work-areas/core.ts | 30 ++++++++++ src/lib/work-areas/server.test.ts | 40 +++++++++++++ src/lib/work-areas/server.ts | 48 +++++++++++++++ 43 files changed, 729 insertions(+), 47 deletions(-) create mode 100644 scripts/apply-work-area-schema.ts create mode 100644 scripts/work-area-schema.ts create mode 100644 src/app/api/cards/[id]/area/route.ts create mode 100644 src/app/api/cards/area-route.test.ts create mode 100644 src/components/work-areas/area-filter.tsx create mode 100644 src/components/work-areas/card-area-section.tsx create mode 100644 src/components/work-areas/work-area-badge.tsx create mode 100644 src/components/work-areas/work-area-picker.tsx create mode 100644 src/lib/work-areas/core.test.ts create mode 100644 src/lib/work-areas/core.ts create mode 100644 src/lib/work-areas/server.test.ts create mode 100644 src/lib/work-areas/server.ts diff --git a/package.json b/package.json index 40c8fc5..baef145 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/apply-work-area-schema.ts b/scripts/apply-work-area-schema.ts new file mode 100644 index 0000000..bdac9e5 --- /dev/null +++ b/scripts/apply-work-area-schema.ts @@ -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; +}); diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 7c09085..098629a 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -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 { 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); diff --git a/scripts/work-area-schema.ts b/scripts/work-area-schema.ts new file mode 100644 index 0000000..35d2ef4 --- /dev/null +++ b/scripts/work-area-schema.ts @@ -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 { + 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; +} diff --git a/src/app/actions/cards.ts b/src/app/actions/cards.ts index 988fc7d..2800b42 100644 --- a/src/app/actions/cards.ts +++ b/src/app/actions/cards.ts @@ -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({ 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`); -} \ No newline at end of file +} diff --git a/src/app/api/cards/[id]/area/route.ts b/src/app/api/cards/[id]/area/route.ts new file mode 100644 index 0000000..1400557 --- /dev/null +++ b/src/app/api/cards/[id]/area/route.ts @@ -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 { + 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; + 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(id).catch(() => null); + if (!card) return jsonError("Card not found", 404); + const project = await pb.collection("projects").getOne(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); + } +} diff --git a/src/app/api/cards/area-route.test.ts b/src/app/api/cards/area-route.test.ts new file mode 100644 index 0000000..9a3cf39 --- /dev/null +++ b/src/app/api/cards/area-route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/client/cards/route.ts b/src/app/api/client/cards/route.ts index 3f90877..441b5b0 100644 --- a/src/app/api/client/cards/route.ts +++ b/src/app/api/client/cards/route.ts @@ -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 { 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 { const card = await pb.collection("cards").create({ 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 { console.error("[client-create] unexpected error", error); return jsonError("Something went wrong", 500); } -} \ No newline at end of file +} diff --git a/src/app/api/client/intake/route.ts b/src/app/api/client/intake/route.ts index b54593b..204362f 100644 --- a/src/app/api/client/intake/route.ts +++ b/src/app/api/client/intake/route.ts @@ -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 { 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 { 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(`column = "${inbox.id}"`, { sort: "-position" }) @@ -97,6 +103,7 @@ export async function POST(request: Request): Promise { 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), diff --git a/src/app/api/public/intake/route.ts b/src/app/api/public/intake/route.ts index c524b4e..a29c1d9 100644 --- a/src/app/api/public/intake/route.ts +++ b/src/app/api/public/intake/route.ts @@ -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 { 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(`column = "${inbox.id}"`, { @@ -125,6 +132,7 @@ export async function POST(request: Request): Promise { 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), diff --git a/src/app/intake/[token]/p/[projectId]/page.tsx b/src/app/intake/[token]/p/[projectId]/page.tsx index 5094c7f..acf2540 100644 --- a/src/app/intake/[token]/p/[projectId]/page.tsx +++ b/src/app/intake/[token]/p/[projectId]/page.tsx @@ -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(resolvedProjectId); + const areas = await pb.collection("work_areas").getFullList({ + filter: pb.filter("project = {:project}", { project: project.id }), sort: "created,name", + }); return (
@@ -65,6 +68,7 @@ export default async function IntakeWizardPage({ projectId={project.id} projectName={project.name} token={token} + areas={areas} />
); diff --git a/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx index 78d4ab0..d65e0f0 100644 --- a/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx +++ b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx @@ -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} /> + + ); -} \ No newline at end of file +} diff --git a/src/app/p/[clientSlug]/[projectSlug]/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/page.tsx index 4f6ab3b..b079c9c 100644 --- a/src/app/p/[clientSlug]/[projectSlug]/page.tsx +++ b/src/app/p/[clientSlug]/[projectSlug]/page.tsx @@ -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({ /> ); -} \ No newline at end of file +} diff --git a/src/app/plan/plan.css b/src/app/plan/plan.css index 1e232e4..3c8bb39 100644 --- a/src/app/plan/plan.css +++ b/src/app/plan/plan.css @@ -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; } diff --git a/src/components/board/add-card.tsx b/src/components/board/add-card.tsx index 6bbb688..89b4a38 100644 --- a/src/components/board/add-card.tsx +++ b/src/components/board/add-card.tsx @@ -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({}); const [pending, startTransition] = useTransition(); const [error, setError] = useState(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" /> +
+ +
+ {error && (

{error} @@ -114,7 +133,7 @@ export function AddCard({

); -} \ No newline at end of file +} diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx index 093e4a5..7e622d6 100644 --- a/src/components/board/board.tsx +++ b/src/components/board/board.tsx @@ -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({ (initialCards); + const [areaFilter, setAreaFilter] = useState("all"); const [activeId, setActiveId] = useState(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 = ( -
+
+ +
{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 ( )} @@ -288,11 +305,13 @@ export function Board({ clientSlug={clientSlug} projectSlug={projectSlug} assistantAvailable={assistantAvailable} + areas={areas} /> )} ); })} +
); @@ -319,9 +338,9 @@ export function Board({ > {content} - {activeCard && } + {activeCard && }
); -} \ No newline at end of file +} diff --git a/src/components/board/card-tile.tsx b/src/components/board/card-tile.tsx index 6b50baa..b860e62 100644 --- a/src/components/board/card-tile.tsx +++ b/src/components/board/card-tile.tsx @@ -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 = { critical: "critical", @@ -27,12 +28,13 @@ export const CardTile = forwardRef< HTMLDivElement, { card: CardRecord; + area?: Pick | null; dragging?: boolean; href?: string; className?: string; children?: React.ReactNode; } & React.HTMLAttributes ->(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} >
@@ -66,6 +69,7 @@ export const CardTile = forwardRef<
+ {area && } {PRIORITY_LABEL[card.priority]} @@ -90,4 +94,4 @@ export const CardTile = forwardRef< {children}
); -}); \ No newline at end of file +}); diff --git a/src/components/intake/brief-preview-public.tsx b/src/components/intake/brief-preview-public.tsx index a8a759b..e1e4dad 100644 --- a/src/components/intake/brief-preview-public.tsx +++ b/src/components/intake/brief-preview-public.tsx @@ -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(null); + const [areaChoice, setAreaChoice] = useState({}); 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({ )} + +
)} + +
+ {project.areas.some((area) => project.cards.some((card) => card.area?.id === area.id)) && ( +
+ AREAS + {project.areas.filter((area) => project.cards.some((card) => card.area?.id === area.id)).map((area) => ( + + ))} +
+ )} {sortPlanningCards(project.cards, sortOrder).map((card) => ( -
+
+ {areas.map((area) => { + const count = cards.filter((card) => card.work_area === area.id).length; + return ( + + ); + })} + {uncategorised > 0 && ( + + )} +
+ ); +} diff --git a/src/components/work-areas/card-area-section.tsx b/src/components/work-areas/card-area-section.tsx new file mode 100644 index 0000000..a5fdbba --- /dev/null +++ b/src/components/work-areas/card-area-section.tsx @@ -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({ 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
+
+

Area

+ {current && } +
+ + + {error &&

{error}

} +
; +} diff --git a/src/components/work-areas/work-area-badge.tsx b/src/components/work-areas/work-area-badge.tsx new file mode 100644 index 0000000..322ace1 --- /dev/null +++ b/src/components/work-areas/work-area-badge.tsx @@ -0,0 +1,16 @@ +import type { WorkAreaRecord } from "@/lib/pb/types"; + +export function WorkAreaBadge({ area, compact = false }: { + area: Pick; + compact?: boolean; +}) { + return ( + + + ); +} diff --git a/src/components/work-areas/work-area-picker.tsx b/src/components/work-areas/work-area-picker.tsx new file mode 100644 index 0000000..d130683 --- /dev/null +++ b/src/components/work-areas/work-area-picker.tsx @@ -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[]; + 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 ( +
+ + + {creating && ( + 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" + /> + )} +

Related cards share an area color. Each card keeps its own status and dates.

+
+ ); +} diff --git a/src/lib/activity/format.test.ts b/src/lib/activity/format.test.ts index 4fbda47..3d2e5f2 100644 --- a/src/lib/activity/format.test.ts +++ b/src/lib/activity/format.test.ts @@ -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); }); -}); \ No newline at end of file +}); diff --git a/src/lib/activity/format.ts b/src/lib/activity/format.ts index 693e9de..603511b 100644 --- a/src/lib/activity/format.ts +++ b/src/lib/activity/format.ts @@ -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"; } -} \ No newline at end of file +} diff --git a/src/lib/cards/client-patch.test.ts b/src/lib/cards/client-patch.test.ts index 539e4b9..c6d8718 100644 --- a/src/lib/cards/client-patch.test.ts +++ b/src/lib/cards/client-patch.test.ts @@ -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); }); -}); \ No newline at end of file +}); diff --git a/src/lib/cards/client-patch.ts b/src/lib/cards/client-patch.ts index fc528a8..1166c76 100644 --- a/src/lib/cards/client-patch.ts +++ b/src/lib/cards/client-patch.ts @@ -29,6 +29,8 @@ export interface ClientCardPatch { export interface ClientCardCreate { title: string; description: string; + areaId: string; + newAreaName: string; } function asRecord(body: unknown): Record | 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 }; -} \ No newline at end of file + return { input: { title, description, areaId, newAreaName }, dropped }; +} diff --git a/src/lib/data/board.ts b/src/lib/data/board.ts index 4c06d87..087fa44 100644 --- a/src/lib/data/board.ts +++ b/src/lib/data/board.ts @@ -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({ - filter: `project = "${project.id}" && archived = false`, - sort: "position", - }); + const [cards, areas] = await Promise.all([ + pb.collection("cards").getFullList({ + filter: `project = "${project.id}" && archived = false`, sort: "position", + }), + pb.collection("work_areas").getFullList({ + filter: `project = "${project.id}"`, sort: "created,name", + }), + ]); - return { client, project, columns, cards }; + return { client, project, columns, cards, areas }; } catch { return null; } diff --git a/src/lib/data/card.ts b/src/lib/data/card.ts index 4198d13..4adc71f 100644 --- a/src/lib/data/card.ts +++ b/src/lib/data/card.ts @@ -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({ + 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; } -} \ No newline at end of file +} diff --git a/src/lib/pb/types.ts b/src/lib/pb/types.ts index dd8cd5a..a5b96fe 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -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; diff --git a/src/lib/planning/load.test.ts b/src/lib/planning/load.test.ts index 59435ea..2462925 100644 --- a/src/lib/planning/load.test.ts +++ b/src/lib/planning/load.test.ts @@ -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]); + }); }); diff --git a/src/lib/planning/load.ts b/src/lib/planning/load.ts index 73eec86..1d28a01 100644 --- a/src/lib/planning/load.ts +++ b/src/lib/planning/load.ts @@ -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 | 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(); 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((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 { 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(), pb.collection("projects").getFullList({ filter: 'status = "active"' }), pb.collection("columns").getFullList(), pb.collection("cards").getFullList({ filter: "archived = false" }), pb.collection("subtasks").getFullList({ fields: "card,done" }), + pb.collection("work_areas").getFullList(), ]); - return buildPlanningBoard({ clients, projects, columns, cards, subtasks }); + return buildPlanningBoard({ clients, projects, columns, cards, subtasks, areas }); } diff --git a/src/lib/planning/sort.test.ts b/src/lib/planning/sort.test.ts index c1e0d10..d72bee0 100644 --- a/src/lib/planning/sort.test.ts +++ b/src/lib/planning/sort.test.ts @@ -18,6 +18,7 @@ function card(id: string, number: number, priority: PlanningCard["priority"], st subtaskDone: 0, subtaskTotal: 0, href: `/cards/${id}`, + area: null, }; } diff --git a/src/lib/work-areas/core.test.ts b/src/lib/work-areas/core.test.ts new file mode 100644 index 0000000..bf9b8c3 --- /dev/null +++ b/src/lib/work-areas/core.test.ts @@ -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(); + }); +}); diff --git a/src/lib/work-areas/core.ts b/src/lib/work-areas/core.ts new file mode 100644 index 0000000..555a4d3 --- /dev/null +++ b/src/lib/work-areas/core.ts @@ -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[]): 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>( + card: { work_area?: string }, + areas: T[] +): T | null { + return areas.find((area) => area.id === card.work_area) ?? null; +} diff --git a/src/lib/work-areas/server.test.ts b/src/lib/work-areas/server.test.ts new file mode 100644 index 0000000..7de2b96 --- /dev/null +++ b/src/lib/work-areas/server.test.ts @@ -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] }); + }); +}); diff --git a/src/lib/work-areas/server.ts b/src/lib/work-areas/server.ts new file mode 100644 index 0000000..6dc5ad7 --- /dev/null +++ b/src/lib/work-areas/server.ts @@ -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 { + return pb.collection("work_areas").getFullList({ + 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 { + 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({ + 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; + } +}