kanban-dev-webapp/docs/superpowers/plans/2026-09-21-phase-10-gantt-plan-redesign.md
Dave 16c8ea3644 docs(plan): Phase 10 implementation plan
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.
2026-09-21 16:07:31 +02:00

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. 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 <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-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 <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 ensureSubtaskDateFields to setup-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 ensureCardAssigneeField to setup-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 buildPlanSections and loadPlan in src/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)
  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.