From 6e231374e2f52cba76adf629d604a52c578bd196 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 17 Sep 2026 07:17:18 +0200 Subject: [PATCH] feat(roadmap): loader scoped by the caller's own read rules --- src/lib/roadmap/load.ts | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/lib/roadmap/load.ts diff --git a/src/lib/roadmap/load.ts b/src/lib/roadmap/load.ts new file mode 100644 index 0000000..b6997a0 --- /dev/null +++ b/src/lib/roadmap/load.ts @@ -0,0 +1,137 @@ +import "server-only"; + +import { getServerPb } from "@/lib/pb/server"; +import { deriveBand, type Band } from "@/lib/roadmap/band"; +import { checklistPercent } from "@/lib/roadmap/progress"; +import type { + CardRecord, + ClientRecord, + ColumnRecord, + ProjectRecord, + SubtaskRecord, +} from "@/lib/pb/types"; + +export interface RoadmapCard { + id: string; + number: number; + title: string; + priority: CardRecord["priority"]; + startDate: string; + targetDate: string; + band: Band | null; + percent: number | null; + projectName: string; + projectSlug: string; + clientName: string; + clientSlug: string; + clientColour: string; + href: string; +} + +export interface RoadmapData { + cards: RoadmapCard[]; + inboxCount: number; + doneCount: number; +} + +/** + * Every card the caller may plan against. + * + * Read with the caller's OWN PocketBase token, so an owner sees every client + * and a client manager sees only theirs. The Phase 1 read rules do the scoping — + * this is not a UI filter, and there is no branch here that could be forgotten. + */ +export async function loadRoadmap(): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return { cards: [], inboxCount: 0, doneCount: 0 }; + + const projects = await pb.collection("projects").getFullList({ + filter: 'status = "active"', + sort: "name", + }); + if (projects.length === 0) return { cards: [], inboxCount: 0, doneCount: 0 }; + + const clients = await pb.collection("clients").getFullList(); + const clientById = new Map(clients.map((c) => [c.id, c])); + + const columns = await pb.collection("columns").getFullList({ + sort: "position", + }); + + const cards = await pb.collection("cards").getFullList({ + filter: "archived = false", + }); + + const subtasks = await pb.collection("subtasks").getFullList({ + fields: "card,done", + }); + + const checklist = new Map(); + for (const subtask of subtasks) { + const entry = checklist.get(subtask.card) ?? { done: 0, total: 0 }; + entry.total += 1; + if (subtask.done) entry.done += 1; + checklist.set(subtask.card, entry); + } + + // Working columns per project, in board order — the index into this is what + // deriveBand reads, so a renamed or added column needs no code change. + const workingByProject = new Map(); + for (const column of columns) { + if (column.is_inbox || column.is_done) continue; + const list = workingByProject.get(column.project) ?? []; + list.push(column); + workingByProject.set(column.project, list); + } + + const columnById = new Map(columns.map((c) => [c.id, c])); + const projectById = new Map(projects.map((p) => [p.id, p])); + + const out: RoadmapCard[] = []; + let inboxCount = 0; + let doneCount = 0; + + for (const card of cards) { + const project = projectById.get(card.project); + const column = columnById.get(card.column); + if (!project || !column) continue; + + const client = clientById.get(project.client); + if (!client) continue; + + if (column.is_inbox) inboxCount += 1; + if (column.is_done) doneCount += 1; + + const working = workingByProject.get(card.project) ?? []; + const counts = checklist.get(card.id); + + const band = deriveBand({ + archived: Boolean(card.archived), + isInbox: Boolean(column.is_inbox), + isDone: Boolean(column.is_done), + columnIndex: working.findIndex((c) => c.id === column.id), + workingColumnCount: working.length, + priority: card.priority, + targetDate: card.target_date ?? "", + }); + + out.push({ + id: card.id, + number: card.number ?? 0, + title: card.title, + priority: card.priority, + startDate: card.start_date ?? "", + targetDate: card.target_date ?? "", + band, + percent: counts ? checklistPercent(counts.done, counts.total) : null, + projectName: project.name, + projectSlug: project.slug, + clientName: client.name, + clientSlug: client.slug, + clientColour: client.brand_color || "var(--brand)", + href: `/p/${client.slug}/${project.slug}/c/${card.id}`, + }); + } + + return { cards: out, inboxCount, doneCount }; +}