diff --git a/docs/superpowers/plans/2026-09-21-phase-10-gantt-plan-redesign.md b/docs/superpowers/plans/2026-09-21-phase-10-gantt-plan-redesign.md new file mode 100644 index 0000000..9670c86 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-phase-10-gantt-plan-redesign.md @@ -0,0 +1,1315 @@ +# Phase 10 — Gantt Plan Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `/plan` page with a project-grouped Gantt where each parent row is a card (start_date..target_date) and indented children are that card's subtasks (now with their own optional start_date and target_date). Reuses the existing `monthTicks` / `weekTicks` / `datePosition` math; no new collections; three new optional fields total. + +**Architecture:** Three small schema adds behind idempotent ensure helpers. A new `loadPlan()` loader returns the sectioned shape the view needs. A new top-level `` composes one `` per active project; each section renders the same flat row array on both label column and timeline track (the invariant the existing `TimelineView` already uses, so labels and bars cannot drift). Card bar = client brand colour, subtask bar = same colour at `opacity-50`. Read-only in this phase — drag-reschedule and inline subtask editing stay out of scope. + +**Tech Stack:** Next.js 16 Server Components / Server Actions · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-21-phase-10-gantt-plan-redesign-design.md` + +**Prerequisite:** Phase 5 complete (the `monthTicks`, `weekTicks`, `datePosition`, `timelineRange` helpers in `src/lib/roadmap/timeline.ts`). Phase 8 (PWA shell) and Phase 9 (intake picker) are independent. + +--- + +## What this plan owns (and does not) + +**In scope** + +- Three new optional fields: `subtasks.start_date`, `subtasks.target_date`, `cards.assignee`. Idempotent `ensureXxxFields` helpers in `scripts/setup-remote.ts`. +- New `loadPlan()` loader that returns `{ sections: PlanSection[] }` where each section carries its project, client, and a list of cards with their dated subtasks pre-grouped. +- Three new React components: `GanttBar`, `ProjectSection`, `GanttPlanView`. +- `/plan/page.tsx` stops using `RoadmapTabs`; the new `` is the only thing rendered. +- One unit test for the pure grouping helper inside `loadPlan`. +- One integration test that the new page renders against the demo data without throwing and shows the seeded project. + +**Out of scope** + +- Drag-to-reschedule on the new bars. +- Inline subtask date editing on the Gantt. +- Removing the legacy `RoadmapTabs` / `TimelineView` / `PuzzleView` / `band-view` files (kept for now; the dashboard tile may still read them). +- Connector lines between parent and child bars. +- Filters by client, priority, or status. + +--- + +## File structure + +``` +src/ +├── lib/ +│ ├── pb/ +│ │ └── types.ts # MODIFY — CardRecord.assignee, +│ │ # SubtaskRecord.start_date / .target_date +│ └── roadmap/ +│ ├── load.ts # MODIFY — add loadPlan() and types +│ └── load.test.ts # NEW — unit tests for the grouping helper +├── app/ +│ └── plan/ +│ └── page.tsx # MODIFY — render +└── components/ + └── roadmap/ + ├── gantt-bar.tsx # NEW — single card or subtask bar + ├── project-section.tsx # NEW — one project's Gantt section + └── gantt-plan-view.tsx # NEW — top-level view, composes sections +scripts/ +└── setup-remote.ts # MODIFY — two new ensure helpers +tests/ +└── integration/ + └── plan-gantt.test.ts # NEW — page renders against demo data +``` + +--- + +## Task 1: Schema — subtask date fields and card assignee + +Implements spec §2. Idempotent helpers in `setup-remote.ts`, type updates in `pb/types.ts`. No tests here — the helpers are simple `if (!have.has(...)) push` patterns identical to `ensureCardNarrativeFields` and `ensureProjectBackgroundMdField`, which already ship. + +**Files:** +- Modify: `scripts/setup-remote.ts` +- Modify: `src/lib/pb/types.ts` + +- [ ] **Step 1: Add `ensureSubtaskDateFields` to `setup-remote.ts`** + +In `scripts/setup-remote.ts`, immediately after `ensureCardNarrativeFields` (line 361), add: + +```ts +async function ensureSubtaskDateFields(pb: PocketBase): Promise { + const subtasks = await pb.collections.getOne("subtasks"); + const have = new Set(subtasks.fields.map((f: { name: string }) => f.name)); + + const fields = [...(subtasks.fields as unknown[])]; + let changed = false; + + for (const field of [ + { name: "start_date", type: "date", required: false }, + { name: "target_date", type: "date", required: false }, + ]) { + if (!have.has(field.name)) { + fields.push({ ...field, presentable: false, hidden: false, system: false }); + changed = true; + } + } + + if (changed) { + await pb.collections.update(subtasks.id, { fields }); + console.log(" + subtasks fields: start_date, target_date"); + } else { + console.log(" = subtasks date fields already present"); + } +} +``` + +- [ ] **Step 2: Add `ensureCardAssigneeField` to `setup-remote.ts`** + +Right after `ensureCardProposalFields` (line 492), add: + +```ts +async function ensureCardAssigneeField(pb: PocketBase): Promise { + const cards = await pb.collections.getOne("cards"); + const have = new Set(cards.fields.map((f: { name: string }) => f.name)); + + if (have.has("assignee")) { + console.log(" = cards.assignee already present"); + return; + } + + const fields = [...(cards.fields as unknown[])]; + fields.push({ + name: "assignee", + type: "text", + required: false, + max: 120, + presentable: false, + hidden: false, + system: false, + }); + await pb.collections.update(cards.id, { fields }); + console.log(" + cards field: assignee"); +} +``` + +- [ ] **Step 3: Wire both helpers into `main()`** + +In the `main()` function, find the block that calls `ensureCardProposalFields` (around line 1526) and add the two new calls directly after it: + +```ts +await ensureCardAssigneeField(pb); +await ensureSubtaskDateFields(pb); +``` + +- [ ] **Step 4: Update `src/lib/pb/types.ts`** + +In `CardRecord` (line 79), add one field: + +```ts + /** Free-text label rendered above the card bar on the Gantt. Optional. */ + assignee: string; +``` + +In `SubtaskRecord` (line 119), add two fields: + +```ts + /** ISO date string, or "" when unset. Mirrors cards.start_date. */ + start_date: string; + /** ISO date string, or "" when unset. Mirrors cards.target_date. */ + target_date: string; +``` + +- [ ] **Step 5: Verify the migration is idempotent** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npx tsc --noEmit +``` + +Then, against the hosted PocketBase: + +```bash +npm run setup-remote +npm run setup-remote +``` + +The second run must print `= cards.assignee already present` and `= subtasks date fields already present` (no `+` lines). Do not commit yet — Tasks 2 and 3 still need to use the new types. + +--- + +## Task 2: Loader — `loadPlan` and its pure grouping helper + +Implements spec §4. The grouping logic is the only non-trivial piece, so it lives as a pure function (`buildPlanSections`) that takes already-fetched rows in and returns the sectioned shape out. The integration shim fetches from PocketBase; the unit test exercises the pure function with hand-built inputs. + +**Files:** +- Modify: `src/lib/roadmap/load.ts` +- Create: `src/lib/roadmap/load.test.ts` + +- [ ] **Step 1: Write the failing unit tests** + +`src/lib/roadmap/load.test.ts`: + +```ts +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"]); + }); +}); +``` + +- [ ] **Step 2: Confirm the tests fail** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm test -- load.test +``` + +Tests fail because `buildPlanSections` does not exist. Expected. + +- [ ] **Step 3: Implement `buildPlanSections` and `loadPlan` in `src/lib/roadmap/load.ts`** + +Add to the bottom of `src/lib/roadmap/load.ts`: + +```ts +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 §3: 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 }); +} +``` + +- [ ] **Step 4: Run the unit tests** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm test -- load +``` + +All five `buildPlanSections` tests pass. Expected. + +- [ ] **Step 5: Confirm types compile** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npx tsc --noEmit +``` + +Clean. + +--- + +## Task 3: `` component + +A single bar — the same markup is used for a card bar (full colour, taller) and a subtask bar (same colour at `opacity-50`, indented, slightly shorter). Read-only: click opens `href` via the wrapping link, hover shows a native title. + +**Files:** +- Create: `src/components/roadmap/gantt-bar.tsx` + +- [ ] **Step 1: Implement ``** + +```tsx +"use client"; + +import { useMemo } from "react"; + +import { datePosition } from "@/lib/roadmap/timeline"; +import type { TimelineRange } from "@/lib/roadmap/timeline"; + +interface GanttBarProps { + href: string; + startDate: string; + targetDate: string; + range: TimelineRange; + /** Hex/rgb colour from the client. */ + color: string; + /** Indent from the left of the track — subtasks use a larger pad. */ + insetLeft: number; + /** Bar height in px. Cards taller, subtasks shorter. */ + height: number; + /** Tooltip text shown on hover (native title attribute). */ + title: string; + /** Optional strikethrough for subtasks whose `done` is true. */ + strike?: boolean; +} + +/** + * A single horizontal bar in the Gantt track. Width is derived from + * the date range against the same `TimelineRange` the header uses, so + * a card and the month ticks above it can never disagree. Falls back to + * a 1-day minimum width so a card with `start_date == target_date` + * still gets a clickable target. + */ +export function GanttBar({ + href, + startDate, + targetDate, + range, + color, + insetLeft, + height, + title, + strike = false, +}: GanttBarProps) { + const MIN_WIDTH = 0.012; + + const { left, width } = useMemo(() => { + const anchor = startDate || targetDate; + const startFraction = datePosition(anchor, range); + const endFraction = Math.max( + startFraction + MIN_WIDTH, + datePosition(targetDate || anchor, range) + ); + return { left: startFraction, width: endFraction - startFraction }; + }, [startDate, targetDate, range]); + + return ( + + {title} + + ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npx tsc --noEmit +``` + +Clean. No tests for the component itself — it's a thin DOM wrapper and the integration test in Task 7 will cover the user-visible behaviour. + +--- + +## Task 4: `` component + +One project. Owns its own label column and its own timeline track. Renders header row + month/week ticks above the bars + a flat row array where each entry drives one label cell and one bar cell. + +**Files:** +- Create: `src/components/roadmap/project-section.tsx` + +- [ ] **Step 1: Implement ``** + +```tsx +"use client"; + +import { useMemo } from "react"; + +import { GanttBar } from "@/components/roadmap/gantt-bar"; +import { + datePosition, + monthTicks, + weekTicks, + type TimelineRange, +} from "@/lib/roadmap/timeline"; +import type { PlanCard, PlanSection } from "@/lib/roadmap/load"; + +const ROW_HEIGHT = 28; +const BAR_HEIGHT_CARD = 14; +const BAR_HEIGHT_SUBTASK = 10; +const HEADER_HEIGHT = 56; +const SUBTASK_INSET = 18; +const LABEL_WIDTH = 220; +const LABEL_PAD_X = 12; + +type Row = + | { kind: "card"; planCard: PlanCard } + | { kind: "subtask"; planCard: PlanCard; subtaskId: string } + | { kind: "undated"; planCard: PlanCard }; + +interface ProjectSectionProps { + section: PlanSection; + range: TimelineRange; +} + +export function ProjectSection({ section, range }: ProjectSectionProps) { + const { project, client, cards } = section; + + /** One flat array drives both sides of the chart so a label and its + * bar can never drift apart. */ + const rows = useMemo(() => { + const out: Row[] = []; + for (const planCard of cards) { + const isDated = Boolean(planCard.startDate || planCard.targetDate); + if (isDated) { + out.push({ kind: "card", planCard }); + for (const subtask of planCard.datedSubtasks) { + out.push({ kind: "subtask", planCard, subtaskId: subtask.id }); + } + } else { + out.push({ kind: "undated", planCard }); + } + } + return out; + }, [cards]); + + const months = monthTicks(range); + const weeks = weekTicks(range); + const today = new Date().toISOString().slice(0, 10); + const todayFraction = datePosition(today, range); + + const trackHeight = HEADER_HEIGHT + Math.max(1, rows.length) * ROW_HEIGHT + 12; + + return ( +
+ {/* Section header */} +
+

+ {project.name} +

+

+ {client.name} · {cards.length} card{cards.length === 1 ? "" : "s"} +

+
+ +
+
+ {/* Label column */} +
+
+ {rows.map((row, idx) => ( +
+ {labelFor(row)} +
+ ))} +
+ + {/* Timeline track */} +
+ {/* Month + week header */} +
+ {months.map((tick) => ( + + {tick.label} + + ))} + {weeks.map((tick) => ( + + {tick.label} + + ))} +
+ + {/* Today marker */} + {todayFraction > 0 && todayFraction < 1 && ( + <> +
+ + Today + + + )} + + {/* Rows */} + {rows.map((row, idx) => { + const top = HEADER_HEIGHT + idx * ROW_HEIGHT + (ROW_HEIGHT - barHeight(row)) / 2; + if (row.kind === "undated") { + return ( +
+ — not scheduled — +
+ ); + } + if (row.kind === "subtask") { + const subtask = row.planCard.datedSubtasks.find( + (s) => s.id === row.subtaskId + ); + if (!subtask) return null; + return ( + + ); + } + return ( + + ); + })} +
+
+
+ + {/* Footer */} +
+ {cards.filter((c) => !c.startDate && !c.targetDate).length} not scheduled + {" · "} + {cards.reduce((n, c) => n + c.undatedSubtaskCount, 0)} subtask + {cards.reduce((n, c) => n + c.undatedSubtaskCount, 0) === 1 ? "" : "s"}{" "} + not scheduled +
+
+ ); +} + +function barHeight(row: Row): number { + return row.kind === "subtask" ? BAR_HEIGHT_SUBTASK : BAR_HEIGHT_CARD; +} + +function labelKey(row: Row, idx: number): string { + if (row.kind === "subtask") return `s-${row.planCard.id}-${row.subtaskId}-${idx}`; + if (row.kind === "undated") return `u-${row.planCard.id}-${idx}`; + return `c-${row.planCard.id}`; +} + +function labelFor(row: Row): React.ReactNode { + if (row.kind === "subtask") { + const subtask = row.planCard.datedSubtasks.find((s) => s.id === row.subtaskId); + return ( + + {subtask?.title ?? ""} + + ); + } + if (row.kind === "undated") { + return ( + {row.planCard.title} + ); + } + return ( + + + {row.planCard.title} + + {row.planCard.assignee && ( + + {row.planCard.assignee} + + )} + + ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npx tsc --noEmit +``` + +Clean. + +--- + +## Task 5: `` top-level component + +Composes one `` per section, plus the "Nothing scheduled yet" empty state. + +**Files:** +- Create: `src/components/roadmap/gantt-plan-view.tsx` + +- [ ] **Step 1: Implement ``** + +```tsx +"use client"; + +import { useMemo } from "react"; + +import { ProjectSection } from "@/components/roadmap/project-section"; +import { timelineRange } from "@/lib/roadmap/timeline"; +import type { PlanSection } from "@/lib/roadmap/load"; + +export function GanttPlanView({ sections }: { sections: PlanSection[] }) { + const range = useMemo(() => { + const datedCards = sections.flatMap((s) => + s.cards.flatMap((c) => [ + ...(c.startDate ? [{ startDate: c.startDate, targetDate: c.targetDate }] : []), + ...c.datedSubtasks.map((st) => ({ + startDate: st.startDate, + targetDate: st.targetDate, + })), + ]) + ); + return timelineRange(datedCards); + }, [sections]); + + if (sections.length === 0) { + return ( +
+

+ Nothing scheduled yet +

+

+ Set a start date and target date on a card to see it appear here. +

+
+ ); + } + + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npx tsc --noEmit +``` + +Clean. + +--- + +## Task 6: Replace `/plan` page body + +`/plan/page.tsx` swaps `` for ``. The `RoadmapStats` KPI strip above is removed — Phase 5 §4.3 said it should be honest about what it counts, and the new Gantt's footer already says "N not scheduled", which is the only count that does not lie. + +**Files:** +- Modify: `src/app/plan/page.tsx` + +- [ ] **Step 1: Rewrite the page body** + +Replace the entire body of `src/app/plan/page.tsx` with: + +```tsx +import { redirect } from "next/navigation"; + +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { GanttPlanView } from "@/components/roadmap/gantt-plan-view"; +import { getSession } from "@/lib/auth/session"; +import { loadPlan } from "@/lib/roadmap/load"; + +export default async function PlanPage() { + const session = await getSession(); + if (!session) redirect("/login"); + + const sections = await loadPlan(); + + return ( + + + +
+
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify the page builds** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run build 2>&1 | tail -30 +``` + +Build clean. (The `band.ts`, `band.test.ts`, `band-view.tsx`, `roadmap-builder.tsx`, `roadmap-card.tsx`, `roadmap-stats.tsx`, `roadmap-tabs.tsx`, `timeline-view.tsx` files are left in place — the dashboard tile may still use them and the diff stays small.) + +--- + +## Task 7: Integration test — the new page renders against the demo data + +Spins up the loader the same way the page does and asserts the resulting shape, then renders the React component to a static string and confirms the section header appears. The loader is the only piece that touches PocketBase; the React render is pure. + +**Files:** +- Create: `tests/integration/plan-gantt.test.ts` + +- [ ] **Step 1: Look at an existing integration test for the conventions** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +sed -n '1,40p' tests/integration/roadmap.test.ts +``` + +Use the imports and test-setup style from `roadmap.test.ts` as a template. If the file uses `pb.collection(...).getFullList` with fixed seed data, mirror that. + +- [ ] **Step 2: Write the integration test** + +`tests/integration/plan-gantt.test.ts`: + +```ts +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { GanttPlanView } from "@/components/roadmap/gantt-plan-view"; +import { loadPlan } from "@/lib/roadmap/load"; +import { getServerPb } from "@/lib/pb/server"; +import type { RecordModel } from "pocketbase"; + +const SEED_PROJECT_NAME = "Client Board"; +const SEED_PROJECT_SLUG = "client-board"; + +describe("/plan Gantt — loadPlan", () => { + it("returns at least one section against the seeded data", async () => { + const sections = await loadPlan(); + expect(sections.length).toBeGreaterThan(0); + const names = sections.map((s) => s.project.name); + expect(names).toContain(SEED_PROJECT_NAME); + }); + + it("includes cards from the seeded project", async () => { + const sections = await loadPlan(); + const ours = sections.find((s) => s.project.slug === SEED_PROJECT_SLUG); + expect(ours).toBeDefined(); + expect(ours!.cards.length).toBeGreaterThan(0); + }); + + it("excludes archived projects", async () => { + const pb = await getServerPb(); + // Find a seeded project and flip it to archived, then reload. + const project = await pb + .collection("projects") + .getFirstListItem(`slug = "${SEED_PROJECT_SLUG}"`); + await pb.collection("projects").update(project.id, { status: "archived" }); + + try { + const sections = await loadPlan(); + expect( + sections.find((s) => s.project.slug === SEED_PROJECT_SLUG) + ).toBeUndefined(); + } finally { + await pb + .collection("projects") + .update(project.id, { status: "active" }); + } + }); +}); + +describe("/plan Gantt — render", () => { + it("renders the section header for each section", async () => { + const sections = await loadPlan(); + const html = renderToStaticMarkup(); + for (const section of sections) { + expect(html).toContain(section.project.name); + } + }); +}); +``` + +- [ ] **Step 3: Run the integration tests** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run test:integration -- plan-gantt +``` + +All three tests pass. (If the test that flips project status to archived interferes with other in-flight tests, move the assertion to use a temporary project instead — see Task 9 final note.) + +--- + +## Task 8: Add a small "scheduled" hint on the card detail page's subtask list + +Implements spec §3.7: when a subtask has dates set, the existing subtask row on the card detail page gets a small inline date label so the user can see the scheduling state without leaving the page. + +**Files:** +- Modify: `src/components/card/subtask-row.tsx` (or wherever the existing subtask row is — locate it first). + +- [ ] **Step 1: Find the existing subtask row component** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +grep -rl "subtask" src/components/card/ 2>/dev/null +``` + +Open the file the grep returns. + +- [ ] **Step 2: Add the date label** + +In the row markup, immediately after the existing `` (or equivalent), add: + +```tsx +{(subtask.start_date || subtask.target_date) && ( + + {formatRange(subtask.start_date, subtask.target_date)} + +)} +``` + +with a local helper at the bottom of the file: + +```ts +function formatRange(start: string, end: string): string { + if (start && end) return `${short(start)} → ${short(end)}`; + if (end) return `by ${short(end)}`; + return `from ${short(start)}`; +} +const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; +function short(iso: string): string { + if (!iso) return "?"; + const month = Number(iso.slice(5, 7)); + return `${Number(iso.slice(8, 10))} ${MONTHS[month - 1] ?? ""}`; +} +``` + +- [ ] **Step 3: Verify the build is still clean** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run build 2>&1 | tail -30 +``` + +--- + +## Task 9: Full verification + +Mirrors the Phase 8 / Phase 9 final verification: automated parts only, with the real test output reported. + +**Files:** none new. + +- [ ] **Step 1: Run the full unit suite** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm test +``` + +Report the pass count. Pre-existing failures in `admin.test.ts` and `plan-puzzle.test.ts` are unrelated to Phase 10 (they predate this work — see `PHASE-8-PROGRESS.md` §8). All new tests must pass. + +- [ ] **Step 2: Run the full integration suite** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run test:integration +``` + +Report the pass count. New `plan-gantt.test.ts` tests must pass. Pre-existing failures in unrelated files are documented. + +- [ ] **Step 3: Build** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run build 2>&1 | tail -10 +``` + +Build clean. + +- [ ] **Step 4: Lint** + +```bash +cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV +npm run lint 2>&1 | tail -20 +``` + +No new warnings. + +- [ ] **Step 5: Manual walkthrough** (documented for the user, not run here) + +1. Sign in as owner, open `/plan`. Every active project renders as a Gantt section; cards with dates are coloured bars, subtasks with dates are indented below them at 50% opacity. +2. Cards with `assignee` show the name to the right of the card title; cards without one show nothing. +3. Cards without dates render as `— not scheduled —` rows under their project's dated rows. +4. Click a bar — it opens the card. +5. Tab through the bars — each is keyboard-focusable; Enter opens the card; focus ring is visible. +6. Hover a bar — native tooltip shows the date range. +7. Open any card, find a subtask that has dates set — the row shows the date range next to the checkbox. + +--- + +## Final note for the executor + +The integration test in Task 7 that flips a project to `archived` mutates a seeded row. The try/finally restores it, but if `npm run test:integration` runs in parallel with other tests against the same seeded project, restore may race. The cheap fix is to seed a throwaway project inside the test (`pb.collection("projects").create(...)` then delete it in `afterAll`), as `tests/integration/intake-link-submissions.test.ts` already does for similar reasons. Adopt that pattern if the current test ever flakes — the plan does not block on it.