From 92c2501726fc6bc5ca7b3a43817d9a618b167e06 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 16:13:47 +0200 Subject: [PATCH] feat(plan): loadPlan loader with pure buildPlanSections helper New loader returns the sectioned shape the Gantt view needs: - buildPlanSections (pure): groups cards by project, sorts by start_date asc / title, splits subtasks into dated vs undated. Five unit tests cover the grouping and sorting rules. - loadPlan (server-only): one PocketBase trip per collection, delegates to buildPlanSections. 471/471 unit tests pass (was 466, +5 new). --- src/lib/roadmap/load.test.ts | 197 +++++++++++++++++++++++++++++++++++ src/lib/roadmap/load.ts | 184 ++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/lib/roadmap/load.test.ts diff --git a/src/lib/roadmap/load.test.ts b/src/lib/roadmap/load.test.ts new file mode 100644 index 0000000..0ca01d3 --- /dev/null +++ b/src/lib/roadmap/load.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; + +import { buildPlanSections } from "@/lib/roadmap/load"; +import type { + CardRecord, + ClientRecord, + ColumnRecord, + ProjectRecord, + SubtaskRecord, +} from "@/lib/pb/types"; + +function project(overrides: Partial = {}): ProjectRecord { + return { + id: "p1", + created: "2026-01-01 00:00:00.000Z", + updated: "2026-01-01 00:00:00.000Z", + client: "c1", + name: "Client Board", + slug: "client-board", + description: "", + status: "active", + kind: "project", + color: "#0ea5e9", + repo_owner: "", + repo_name: "", + repo_url: "", + webhook_secret: "", + last_ingest_at: "", + session_gap_minutes: 15, + pre_session_minutes: 5, + post_session_minutes: 5, + background_md: "", + show_in_intake: false, + ...overrides, + }; +} + +function card(overrides: Partial = {}): CardRecord { + return { + id: "k1", + created: "2026-01-01 00:00:00.000Z", + updated: "2026-01-01 00:00:00.000Z", + project: "p1", + column: "col1", + title: "Refactor billing modal", + description: "", + position: 0, + priority: "medium", + target_date: "", + created_by: "", + created_by_role: "owner", + accepted: true, + start_date: "", + scope_rule: "", + archived: false, + number: 1, + narrative: "", + narrative_version: 0, + narrative_shas: "", + brief: null, + transcript: null, + size_band: "", + assignee: "", + ...overrides, + }; +} + +function subtask(overrides: Partial = {}): SubtaskRecord { + return { + id: "s1", + created: "2026-01-01 00:00:00.000Z", + updated: "2026-01-01 00:00:00.000Z", + card: "k1", + title: "Write tests", + done: false, + position: 0, + start_date: "", + target_date: "", + ...overrides, + }; +} + +function column(overrides: Partial = {}): ColumnRecord { + return { + id: "col1", + created: "", + updated: "", + project: "p1", + name: "To Do", + position: 0, + is_inbox: false, + is_done: false, + ...overrides, + }; +} + +function client(overrides: Partial = {}): ClientRecord { + return { + id: "c1", + created: "", + updated: "", + name: "Acme", + slug: "acme", + brand_color: "#0ea5e9", + active: true, + ...overrides, + }; +} + +describe("buildPlanSections — groups cards by project, sorts by date", () => { + it("returns one section per active project, sorted client-then-project", () => { + const sections = buildPlanSections({ + clients: [client({ id: "c2", name: "Bravo" }), client({ id: "c1", name: "Acme" })], + projects: [ + project({ id: "p2", client: "c2", name: "Bravo Site", slug: "bravo-site" }), + project({ id: "p1", client: "c1", name: "Acme App", slug: "acme-app" }), + ], + columns: [column()], + cards: [card({ project: "p2" }), card({ project: "p1" })], + subtasks: [], + }); + + expect(sections.map((s) => s.project.name)).toEqual(["Acme App", "Bravo Site"]); + }); + + it("excludes archived projects and Done column cards", () => { + const sections = buildPlanSections({ + clients: [client()], + projects: [project({ status: "archived" })], + columns: [ + column({ id: "col-done", name: "Done", is_done: true }), + column({ id: "col-todo", name: "To Do" }), + ], + cards: [ + card({ id: "k-archived-project", column: "col-todo" }), + card({ id: "k-done", column: "col-done" }), + ], + subtasks: [], + }); + + expect(sections).toEqual([]); + }); + + it("sorts cards within a project: dated first by start_date asc, then undated by title", () => { + const sections = buildPlanSections({ + clients: [client()], + projects: [project()], + columns: [column()], + cards: [ + card({ id: "z-late", title: "Late", start_date: "2026-03-01", target_date: "2026-03-15" }), + card({ id: "a-early", title: "Early", start_date: "2026-02-01", target_date: "2026-02-10" }), + card({ id: "n-zeta", title: "Zeta" }), + card({ id: "n-alpha", title: "Alpha" }), + ], + subtasks: [], + }); + + expect(sections[0].cards.map((c) => c.id)).toEqual([ + "a-early", + "z-late", + "n-alpha", + "n-zeta", + ]); + }); + + it("groups subtasks under their parent card and splits dated vs undated", () => { + const sections = buildPlanSections({ + clients: [client()], + projects: [project()], + columns: [column()], + cards: [card({ id: "k1", start_date: "2026-02-01", target_date: "2026-02-10" })], + subtasks: [ + subtask({ id: "s-dated", position: 1, start_date: "2026-02-01", target_date: "2026-02-05" }), + subtask({ id: "s-undated", position: 0 }), + ], + }); + + const c = sections[0].cards[0]; + expect(c.datedSubtasks.map((s) => s.id)).toEqual(["s-dated"]); + expect(c.undatedSubtaskCount).toBe(1); + }); + + it("sorts subtasks by position within a card", () => { + const sections = buildPlanSections({ + clients: [client()], + projects: [project()], + columns: [column()], + cards: [card({ start_date: "2026-02-01", target_date: "2026-02-10" })], + subtasks: [ + subtask({ id: "s2", position: 2, start_date: "2026-02-01", target_date: "2026-02-05" }), + subtask({ id: "s1", position: 1, start_date: "2026-02-01", target_date: "2026-02-03" }), + ], + }); + + expect(sections[0].cards[0].datedSubtasks.map((s) => s.id)).toEqual(["s1", "s2"]); + }); +}); diff --git a/src/lib/roadmap/load.ts b/src/lib/roadmap/load.ts index 8c423ac..eb4066c 100644 --- a/src/lib/roadmap/load.ts +++ b/src/lib/roadmap/load.ts @@ -163,3 +163,187 @@ export async function loadRoadmap(): Promise { return { cards: out, inboxCount, doneCount }; } + +export interface PlanSubtask { + id: string; + title: string; + done: boolean; + startDate: string; + targetDate: string; +} + +export interface PlanCard { + id: string; + title: string; + priority: CardRecord["priority"]; + startDate: string; + targetDate: string; + assignee: string; + href: string; + datedSubtasks: PlanSubtask[]; + undatedSubtaskCount: number; +} + +export interface PlanSection { + project: { id: string; name: string; slug: string }; + client: { id: string; name: string; slug: string; brandColor: string }; + cards: PlanCard[]; +} + +interface BuildPlanInput { + clients: ClientRecord[]; + projects: ProjectRecord[]; + columns: ColumnRecord[]; + cards: CardRecord[]; + subtasks: SubtaskRecord[]; +} + +/** + * Pure: takes already-fetched PocketBase rows and returns the sectioned + * shape the Gantt view renders. Splitting this out lets the unit tests + * exercise the grouping logic without a database connection. + * + * Sort rules match the spec: cards within a project are dated-first + * (by start_date asc, then target_date asc), then undated by title; + * subtasks within a card are sorted by `position`. + */ +export function buildPlanSections({ + clients, + projects, + columns, + cards, + subtasks, +}: BuildPlanInput): PlanSection[] { + const clientsById = new Map(clients.map((c) => [c.id, c])); + const projectsById = new Map(projects.map((p) => [p.id, p])); + const columnsById = new Map(columns.map((c) => [c.id, c])); + + const doneColumnIds = new Set( + columns.filter((c) => c.is_done).map((c) => c.id) + ); + + // Drop cards that live in a Done column — the Gantt is for planned work. + const liveCards = cards.filter( + (card) => + !card.archived && + !doneColumnIds.has(card.column) && + projectsById.has(card.project) + ); + + // Group subtasks by card id, then split dated vs undated. + const subtasksByCard = new Map(); + for (const subtask of subtasks) { + const list = subtasksByCard.get(subtask.card) ?? []; + list.push(subtask); + subtasksByCard.set(subtask.card, list); + } + + // Build plan cards, attaching their split subtask lists. + const planCardsByProject = new Map(); + for (const card of liveCards) { + const allSubtasks = subtasksByCard.get(card.id) ?? []; + const datedSubtasks = allSubtasks + .filter((s) => Boolean(s.start_date) || Boolean(s.target_date)) + .sort((a, b) => a.position - b.position) + .map((s) => ({ + id: s.id, + title: s.title, + done: s.done, + startDate: s.start_date ?? "", + targetDate: s.target_date ?? "", + })); + const undatedSubtaskCount = allSubtasks.length - datedSubtasks.length; + + const project = projectsById.get(card.project); + if (!project) continue; + const client = clientsById.get(project.client); + if (!client) continue; + + const planCard: PlanCard = { + id: card.id, + title: card.title, + priority: card.priority, + startDate: card.start_date ?? "", + targetDate: card.target_date ?? "", + assignee: card.assignee ?? "", + href: `/p/${client.slug}/${project.slug}/c/${card.id}`, + datedSubtasks, + undatedSubtaskCount, + }; + + const list = planCardsByProject.get(card.project) ?? []; + list.push(planCard); + planCardsByProject.set(card.project, list); + } + + // Sort cards inside each project: dated first by start asc, then by target asc, + // then undated by title. + for (const list of planCardsByProject.values()) { + list.sort(comparePlanCards); + } + + // Build sections in client-then-project alphabetical order, skip projects + // that have no live cards. + const sections: PlanSection[] = []; + const projectsSorted = [...projects] + .filter((p) => p.status === "active" && planCardsByProject.has(p.id)) + .sort((a, b) => { + const ca = clientsById.get(a.client)?.name ?? ""; + const cb = clientsById.get(b.client)?.name ?? ""; + return ca.localeCompare(cb) || a.name.localeCompare(b.name); + }); + + for (const project of projectsSorted) { + const client = clientsById.get(project.client); + if (!client) continue; + sections.push({ + project: { id: project.id, name: project.name, slug: project.slug }, + client: { + id: client.id, + name: client.name, + slug: client.slug, + brandColor: client.brand_color || "var(--brand)", + }, + cards: planCardsByProject.get(project.id) ?? [], + }); + } + + return sections; +} + +function comparePlanCards(a: PlanCard, b: PlanCard): number { + const aDated = Boolean(a.startDate) || Boolean(a.targetDate); + const bDated = Boolean(b.startDate) || Boolean(b.targetDate); + if (aDated && !bDated) return -1; + if (!aDated && bDated) return 1; + if (aDated && bDated) { + return ( + (a.startDate || a.targetDate).localeCompare(b.startDate || b.targetDate) || + a.title.localeCompare(b.title) + ); + } + return a.title.localeCompare(b.title); +} + +/** + * Server-only: read every row the Gantt needs in one pass per collection + * and hand it to `buildPlanSections`. Returns an empty array when no + * active projects have any live (non-archived, non-Done) cards. + */ +export async function loadPlan(): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return []; + + const [clients, projects, columns, cards, subtasks] = await Promise.all([ + pb.collection("clients").getFullList(), + pb.collection("projects").getFullList({ + filter: 'status = "active"', + sort: "name", + }), + pb.collection("columns").getFullList({ sort: "position" }), + pb.collection("cards").getFullList({ filter: "archived = false" }), + pb.collection("subtasks").getFullList(), + ]); + + return buildPlanSections({ clients, projects, columns, cards, subtasks }); +}