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:
parent
a5dc6083dd
commit
92c2501726
2 changed files with 381 additions and 0 deletions
197
src/lib/roadmap/load.test.ts
Normal file
197
src/lib/roadmap/load.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue