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).
This commit is contained in:
Dave 2026-09-21 16:13:47 +02:00
parent a5dc6083dd
commit 92c2501726
2 changed files with 381 additions and 0 deletions

View file

@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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"]);
});
});

View file

@ -163,3 +163,187 @@ export async function loadRoadmap(): Promise<RoadmapData> {
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<string, SubtaskRecord[]>();
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<string, PlanCard[]>();
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<PlanSubtask>((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<PlanSection[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const [clients, projects, columns, cards, subtasks] = await Promise.all([
pb.collection("clients").getFullList<ClientRecord>(),
pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status = "active"',
sort: "name",
}),
pb.collection("columns").getFullList<ColumnRecord>({ sort: "position" }),
pb.collection("cards").getFullList<CardRecord>({ filter: "archived = false" }),
pb.collection("subtasks").getFullList<SubtaskRecord>(),
]);
return buildPlanSections({ clients, projects, columns, cards, subtasks });
}