Nine tasks: schema adds, loadPlan + pure grouping helper, GanttBar / ProjectSection / GanttPlanView components, page wire-up, integration test, and full verification. Drag and inline subtask editing stay out of scope.
42 KiB
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 <GanttPlanView> composes one <ProjectSection> 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. IdempotentensureXxxFieldshelpers inscripts/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.tsxstops usingRoadmapTabs; the new<GanttPlanView>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-viewfiles (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 <GanttPlanView>
└── 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
ensureSubtaskDateFieldstosetup-remote.ts
In scripts/setup-remote.ts, immediately after ensureCardNarrativeFields (line 361), add:
async function ensureSubtaskDateFields(pb: PocketBase): Promise<void> {
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
ensureCardAssigneeFieldtosetup-remote.ts
Right after ensureCardProposalFields (line 492), add:
async function ensureCardAssigneeField(pb: PocketBase): Promise<void> {
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:
await ensureCardAssigneeField(pb);
await ensureSubtaskDateFields(pb);
- Step 4: Update
src/lib/pb/types.ts
In CardRecord (line 79), add one field:
/** Free-text label rendered above the card bar on the Gantt. Optional. */
assignee: string;
In SubtaskRecord (line 119), add two fields:
/** 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
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npx tsc --noEmit
Then, against the hosted PocketBase:
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:
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"]);
});
});
- Step 2: Confirm the tests fail
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npm test -- load.test
Tests fail because buildPlanSections does not exist. Expected.
- Step 3: Implement
buildPlanSectionsandloadPlaninsrc/lib/roadmap/load.ts
Add to the bottom of src/lib/roadmap/load.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<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 });
}
- Step 4: Run the unit tests
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npm test -- load
All five buildPlanSections tests pass. Expected.
- Step 5: Confirm types compile
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npx tsc --noEmit
Clean.
Task 3: <GanttBar> 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
<GanttBar>
"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 (
<a
href={href}
role="button"
tabIndex={0}
title={title}
className={`absolute flex items-center overflow-hidden rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] ${
strike ? "line-through" : ""
}`}
style={{
left: `calc(${(left * 100).toFixed(3)}% + ${insetLeft}px)`,
width: `calc(${(width * 100).toFixed(3)}% - ${insetLeft}px)`,
top: 0,
height,
backgroundColor: color,
}}
>
<span className="sr-only">{title}</span>
</a>
);
}
- Step 2: Verify it compiles
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: <ProjectSection> 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
<ProjectSection>
"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<Row[]>(() => {
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
aria-label={`${project.name} timeline`}
className="rounded-[var(--radius-tile)] border border-[var(--border-subtle)] bg-[var(--surface)]"
>
{/* Section header */}
<header
className="flex items-baseline justify-between border-b border-[var(--border-subtle)] px-4 py-2.5"
style={{ borderTop: `2px solid ${client.brandColor}` }}
>
<h3 className="text-base font-semibold text-[var(--text-primary)]">
{project.name}
</h3>
<p className="text-xs text-[var(--text-muted)]">
{client.name} · {cards.length} card{cards.length === 1 ? "" : "s"}
</p>
</header>
<div className="overflow-x-auto">
<div className="flex min-w-[640px]">
{/* Label column */}
<div
className="shrink-0 border-r border-[var(--border-subtle)]"
style={{ width: LABEL_WIDTH }}
>
<div style={{ height: HEADER_HEIGHT }} aria-hidden />
{rows.map((row, idx) => (
<div
key={labelKey(row, idx)}
className="flex items-center truncate text-sm"
style={{
height: ROW_HEIGHT,
paddingLeft:
row.kind === "subtask"
? SUBTASK_INSET + LABEL_PAD_X
: LABEL_PAD_X,
}}
>
{labelFor(row)}
</div>
))}
</div>
{/* Timeline track */}
<div className="relative flex-1" style={{ height: trackHeight }}>
{/* Month + week header */}
<div
className="absolute left-0 right-0 top-0 border-b border-[var(--border-subtle)]"
style={{ height: HEADER_HEIGHT }}
>
{months.map((tick) => (
<span
key={`m-${tick.date}`}
className="absolute top-1 text-[11px] font-medium text-[var(--text-secondary)]"
style={{ left: `${tick.position * 100}%` }}
>
{tick.label}
</span>
))}
{weeks.map((tick) => (
<span
key={`w-${tick.date}`}
className="absolute bottom-1 text-[10px] tabular-nums text-[var(--text-muted)]"
style={{ left: `${tick.position * 100}%` }}
>
{tick.label}
</span>
))}
</div>
{/* Today marker */}
{todayFraction > 0 && todayFraction < 1 && (
<>
<div
aria-hidden
className="absolute top-0 bottom-0 w-px bg-[var(--accent)]"
style={{
left: `${todayFraction * 100}%`,
top: HEADER_HEIGHT - 6,
height: trackHeight - HEADER_HEIGHT + 6,
}}
/>
<span
className="absolute rounded-full bg-[var(--accent)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent-fg)]"
style={{
left: `calc(${todayFraction * 100}% + 4px)`,
top: HEADER_HEIGHT - 18,
}}
>
Today
</span>
</>
)}
{/* Rows */}
{rows.map((row, idx) => {
const top = HEADER_HEIGHT + idx * ROW_HEIGHT + (ROW_HEIGHT - barHeight(row)) / 2;
if (row.kind === "undated") {
return (
<div
key={`u-${row.planCard.id}`}
className="absolute left-3 right-3 flex items-center text-xs italic text-[var(--text-muted)]"
style={{ top: HEADER_HEIGHT + idx * ROW_HEIGHT, height: ROW_HEIGHT }}
>
— not scheduled —
</div>
);
}
if (row.kind === "subtask") {
const subtask = row.planCard.datedSubtasks.find(
(s) => s.id === row.subtaskId
);
if (!subtask) return null;
return (
<GanttBar
key={`s-${subtask.id}`}
href={row.planCard.href}
startDate={subtask.startDate}
targetDate={subtask.targetDate}
range={range}
color={client.brandColor}
insetLeft={SUBTASK_INSET}
height={BAR_HEIGHT_SUBTASK}
title={`${subtask.title} — ${subtask.startDate || "?"} → ${subtask.targetDate || "?"}`}
strike={subtask.done}
/>
);
}
return (
<GanttBar
key={`c-${row.planCard.id}`}
href={row.planCard.href}
startDate={row.planCard.startDate}
targetDate={row.planCard.targetDate}
range={range}
color={client.brandColor}
insetLeft={0}
height={BAR_HEIGHT_CARD}
title={`${row.planCard.title} — ${row.planCard.startDate || "?"} → ${row.planCard.targetDate || "?"}`}
/>
);
})}
</div>
</div>
</div>
{/* Footer */}
<footer className="border-t border-[var(--border-subtle)] px-4 py-2 text-xs text-[var(--text-muted)]">
{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
</footer>
</section>
);
}
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 (
<span className={`truncate text-[var(--text-secondary)] ${subtask?.done ? "line-through" : ""}`}>
{subtask?.title ?? ""}
</span>
);
}
if (row.kind === "undated") {
return (
<span className="truncate text-[var(--text-primary)]">{row.planCard.title}</span>
);
}
return (
<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate font-medium text-[var(--text-primary)]">
{row.planCard.title}
</span>
{row.planCard.assignee && (
<span className="shrink-0 text-xs text-[var(--text-muted)]">
{row.planCard.assignee}
</span>
)}
</span>
);
}
- Step 2: Verify it compiles
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npx tsc --noEmit
Clean.
Task 5: <GanttPlanView> top-level component
Composes one <ProjectSection> per section, plus the "Nothing scheduled yet" empty state.
Files:
-
Create:
src/components/roadmap/gantt-plan-view.tsx -
Step 1: Implement
<GanttPlanView>
"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 (
<div className="rounded-[var(--radius-tile)] border-2 border-dashed border-[var(--border-subtle)] px-4 py-12 text-center text-[var(--text-muted)]">
<h2 className="text-base font-medium text-[var(--text-primary)]">
Nothing scheduled yet
</h2>
<p className="mx-auto mt-1.5 max-w-sm text-sm">
Set a start date and target date on a card to see it appear here.
</p>
</div>
);
}
return (
<div className="space-y-4">
{sections.map((section) => (
<ProjectSection
key={section.project.id}
section={section}
range={range}
/>
))}
</div>
);
}
- Step 2: Verify it compiles
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npx tsc --noEmit
Clean.
Task 6: Replace /plan page body
/plan/page.tsx swaps <RoadmapTabs> for <GanttPlanView>. 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:
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 (
<AppShell role={session.role} currentPath="/plan">
<TopBar
title="Planning"
subtitle={
session.role === "owner"
? "Everything scheduled, across every client"
: "What is scheduled for your projects"
}
/>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-6xl space-y-6 p-6">
<GanttPlanView sections={sections} />
</div>
</div>
</AppShell>
);
}
- Step 2: Verify the page builds
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
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:
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(<GanttPlanView sections={sections} />);
for (const section of sections) {
expect(html).toContain(section.project.name);
}
});
});
- Step 3: Run the integration tests
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
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 <input type="checkbox"> (or equivalent), add:
{(subtask.start_date || subtask.target_date) && (
<span
className="ml-2 text-xs text-[var(--text-muted)] tabular-nums"
aria-label={`Scheduled ${subtask.start_date || "?"} to ${subtask.target_date || "?"}`}
>
{formatRange(subtask.start_date, subtask.target_date)}
</span>
)}
with a local helper at the bottom of the file:
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
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
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
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
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/KANBANBOARD-DEV
npm run build 2>&1 | tail -10
Build clean.
- Step 4: Lint
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)
- 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. - Cards with
assigneeshow the name to the right of the card title; cards without one show nothing. - Cards without dates render as
— not scheduled —rows under their project's dated rows. - Click a bar — it opens the card.
- Tab through the bars — each is keyboard-focusable; Enter opens the card; focus ring is visible.
- Hover a bar — native tooltip shows the date range.
- 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.