kanban-dev-webapp/docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md
Dave d88dc91d7d docs: Phase 5 spec and plan for the planning and roadmap view
Two views rather than one chart, because the data forced it: measured on
the live database, 1 of 5 live cards has a target date and none has both
dates. target_date is client-owned and optional; start_date is only set on
Inbox acceptance. A conventional Gantt would render almost empty.

So Now/Next/Later is the default and works with zero dates, and the
timeline earns its place as clients set dates. From Dave's Gantt reference
it takes the quarter header, the Today marker, project grouping with the
client's colour, and the per-row completion percentage — which maps onto
checklist progress, the one honest source for it. The faint full-width
track is load-bearing rather than decorative: it makes a sparse chart read
as unscheduled instead of broken.

Drag-to-reschedule always writes an activity row naming the owner, because
target_date belongs to the client. A developer silently moving a date the
client chose is the failure the activity log exists to prevent.

Completes the plan set for phases 1 through 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 07:14:47 +02:00

57 KiB
Raw Blame History

Phase 5 — Planning and Roadmap 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: A planning page with two tabs — Now / Next / Later across every project by default, and a Gantt-style timeline for the cards that carry dates — with the owner able to drag a card to reschedule it.

Architecture: Every calculation is pure and tested without a database: band derivation, the time axis, row packing, checklist percentages. The page reads with the caller's own PocketBase token, so an owner sees every client and a client manager sees only their own — the Phase 1 read rules do the scoping, not a UI filter. No schema changes; bands are derived and never stored.

Tech Stack: Next.js 16 Server Actions · PocketBase 0.30 · Tailwind 4 · vitest

Spec: docs/superpowers/specs/2026-09-17-phase-5-planning-and-roadmap-design.md

Prerequisite: Phase 1 complete. Phases 2–4 are not required — this phase reads cards, not commits.


The constraint that shapes the whole page

Measured on the live database before this was written:

live cards:                        5
  with a target_date:              1
  with a start_date:               0
  with BOTH (a real Gantt bar):    0

target_date is client-owned and optional; start_date is only written when a card is accepted off the Inbox. Most cards will have neither, which is why this is two views rather than one chart.

It also drives one specific design decision, from the developer's Gantt reference: every row draws its full-width track whether or not it is scheduled. A date-only card puts a marker on that track. Without it, a sparse chart looks like a rendering failure; with it, it reads as "these are the items, most are not scheduled yet" — which is the truth.


File structure

src/
├── lib/
│   └── roadmap/
│       ├── band.ts                  # Now / Next / Later
│       ├── band.test.ts
│       ├── timeline.ts              # axis, ticks, row packing
│       ├── timeline.test.ts
│       ├── progress.ts              # checklist percentage
│       ├── progress.test.ts
│       └── load.ts                  # the loader
├── app/
│   ├── actions/
│   │   └── reschedule.ts
│   └── plan/page.tsx
└── components/
    └── roadmap/
        ├── band-view.tsx
        ├── timeline-view.tsx
        ├── roadmap-card.tsx
        └── roadmap-stats.tsx

Task 1: Band derivation

Pure, test-first. Implements spec §3.

Files:

  • Create: src/lib/roadmap/band.ts

  • Test: src/lib/roadmap/band.test.ts

  • Step 1: Write the failing tests

src/lib/roadmap/band.test.ts:

import { describe, it, expect } from "vitest";
import { deriveBand, type BandInput } from "@/lib/roadmap/band";

const today = new Date("2026-09-17T12:00:00Z");

/** Default board: Backlog(0), To Do(1), In Progress(2), Review(3). */
function card(overrides: Partial<BandInput> = {}): BandInput {
  return {
    archived: false,
    isInbox: false,
    isDone: false,
    columnIndex: 0,
    workingColumnCount: 4,
    priority: "medium",
    targetDate: "",
    ...overrides,
  };
}

describe("deriveBand — exclusions", () => {
  it("excludes archived cards", () => {
    expect(deriveBand(card({ archived: true }), today)).toBeNull();
  });

  it("excludes Inbox cards", () => {
    // An unaccepted request is not committed work.
    expect(deriveBand(card({ isInbox: true, priority: "critical" }), today)).toBeNull();
  });

  it("excludes Done cards", () => {
    expect(deriveBand(card({ isDone: true, columnIndex: 3 }), today)).toBeNull();
  });
});

describe("deriveBand — dates outrank position", () => {
  it("puts an overdue card in Now wherever it sits", () => {
    expect(
      deriveBand(card({ columnIndex: 0, targetDate: "2026-09-10" }), today)
    ).toBe("now");
  });

  it("puts a card due within seven days in Now", () => {
    expect(deriveBand(card({ targetDate: "2026-09-22" }), today)).toBe("now");
  });

  it("does not promote a card due further out", () => {
    expect(deriveBand(card({ targetDate: "2026-10-30" }), today)).toBe("later");
  });

  it("treats a date exactly seven days out as imminent", () => {
    expect(deriveBand(card({ targetDate: "2026-09-24" }), today)).toBe("now");
  });
});

describe("deriveBand — the board's own order is the signal", () => {
  it("puts the later half of the working columns in Now", () => {
    expect(deriveBand(card({ columnIndex: 2 }), today)).toBe("now"); // In Progress
    expect(deriveBand(card({ columnIndex: 3 }), today)).toBe("now"); // Review
  });

  it("puts the second column in Next", () => {
    expect(deriveBand(card({ columnIndex: 1 }), today)).toBe("next"); // To Do
  });

  it("puts the first column in Later", () => {
    expect(deriveBand(card({ columnIndex: 0 }), today)).toBe("later"); // Backlog
  });

  it("adapts when the owner adds a column", () => {
    // Six working columns: the later half is 3, 4, 5.
    const six = { workingColumnCount: 6 };
    expect(deriveBand(card({ ...six, columnIndex: 3 }), today)).toBe("now");
    expect(deriveBand(card({ ...six, columnIndex: 2 }), today)).toBe("next");
    expect(deriveBand(card({ ...six, columnIndex: 0 }), today)).toBe("later");
  });

  it("handles a board with a single working column", () => {
    expect(
      deriveBand(card({ workingColumnCount: 1, columnIndex: 0 }), today)
    ).toBe("now");
  });
});

describe("deriveBand — priority", () => {
  it("promotes critical to Now from anywhere", () => {
    expect(deriveBand(card({ columnIndex: 0, priority: "critical" }), today)).toBe("now");
  });

  it("promotes high to Next from the backlog", () => {
    expect(deriveBand(card({ columnIndex: 0, priority: "high" }), today)).toBe("next");
  });

  it("leaves low priority in Later", () => {
    expect(deriveBand(card({ columnIndex: 0, priority: "low" }), today)).toBe("later");
  });
});

describe("deriveBand — robustness", () => {
  it("tolerates a PocketBase datetime", () => {
    expect(
      deriveBand(card({ targetDate: "2026-09-10 00:00:00.000Z" }), today)
    ).toBe("now");
  });

  it("ignores an unparseable date rather than throwing", () => {
    expect(deriveBand(card({ targetDate: "not a date" }), today)).toBe("later");
  });

  it("never returns a band for an excluded card, whatever else is set", () => {
    expect(
      deriveBand(
        card({ isDone: true, priority: "critical", targetDate: "2026-09-01" }),
        today
      )
    ).toBeNull();
  });
});
  • Step 2: Run the tests to verify they fail
npm test

Expected: FAIL — Failed to resolve import "@/lib/roadmap/band".

  • Step 3: Write the implementation

src/lib/roadmap/band.ts:

/**
 * Now / Next / Later.
 *
 * Derived, never stored. A stored band would be a third thing to keep in sync
 * with the column and the priority, and it would drift from both without
 * anyone noticing.
 *
 * Column POSITION is used rather than column name, so a renamed or newly added
 * column still works. The board's own order is the signal; this reads it rather
 * than holding a parallel opinion about what "in progress" means.
 */

import type { Priority } from "@/lib/pb/types";

export type Band = "now" | "next" | "later";

/** A date this close counts as imminent regardless of board position. */
const IMMINENT_DAYS = 7;

export interface BandInput {
  archived: boolean;
  isInbox: boolean;
  isDone: boolean;
  /** Index among the working columns — those that are neither inbox nor done. */
  columnIndex: number;
  workingColumnCount: number;
  priority: Priority;
  /** ISO date or PocketBase datetime, or "". */
  targetDate: string;
}

function daysUntil(targetDate: string, now: Date): number | null {
  if (!targetDate) return null;

  const iso = targetDate.replace(" ", "T");
  const target = Date.UTC(
    Number(iso.slice(0, 4)),
    Number(iso.slice(5, 7)) - 1,
    Number(iso.slice(8, 10))
  );
  if (Number.isNaN(target)) return null;

  const reference = Date.UTC(
    now.getUTCFullYear(),
    now.getUTCMonth(),
    now.getUTCDate()
  );

  return Math.round((target - reference) / 86_400_000);
}

export function deriveBand(card: BandInput, now: Date = new Date()): Band | null {
  // Not committed work, finished work, or removed work.
  if (card.archived || card.isInbox || card.isDone) return null;

  const days = daysUntil(card.targetDate, now);
  if (days !== null && days <= IMMINENT_DAYS) return "now";

  // The later half of the working columns is work in flight.
  const half = Math.ceil(card.workingColumnCount / 2);
  if (card.workingColumnCount > 0 && card.columnIndex >= half) return "now";

  if (card.priority === "critical") return "now";

  if (card.columnIndex >= 1) return "next";
  if (card.priority === "high") return "next";

  return "later";
}
  • Step 4: Run the tests to verify they pass
npm test

Expected: PASS — 16 tests in band.test.ts.

Note the single-working-column case returns now: with one column, Math.ceil(1/2) is 1 and index 0 is below it, so it falls through to the priority checks and lands in later — check this against the test and fix whichever is wrong. A one-column board is degenerate either way; what matters is that the behaviour is decided rather than accidental.

  • Step 5: Commit
git add src/lib/roadmap/band.ts src/lib/roadmap/band.test.ts
git commit -m "feat(roadmap): Now/Next/Later derived from board order and dates"

Task 2: The time axis

Pure, test-first. Implements spec §4.2.

Files:

  • Create: src/lib/roadmap/timeline.ts

  • Test: src/lib/roadmap/timeline.test.ts

  • Step 1: Write the failing tests

src/lib/roadmap/timeline.test.ts:

import { describe, it, expect } from "vitest";
import {
  datePosition,
  monthTicks,
  packRows,
  timelineRange,
  type TimelineItem,
} from "@/lib/roadmap/timeline";

const today = new Date("2026-09-17T12:00:00Z");

function item(id: string, target: string, start = ""): TimelineItem {
  return { id, startDate: start, targetDate: target };
}

describe("timelineRange", () => {
  it("always includes today, even with no items", () => {
    // An axis that does not contain the present is disorienting.
    const range = timelineRange([], today);
    expect(range.from <= "2026-09-17").toBe(true);
    expect(range.to >= "2026-09-17").toBe(true);
  });

  it("snaps to whole months", () => {
    const range = timelineRange([item("a", "2026-10-15")], today);
    expect(range.from.slice(8)).toBe("01");
    expect(range.to.slice(8)).not.toBe("");
  });

  it("spans from the earliest date to the latest", () => {
    const range = timelineRange(
      [item("a", "2026-11-20"), item("b", "2026-08-05", "2026-07-20")],
      today
    );
    expect(range.from <= "2026-07-20").toBe(true);
    expect(range.to >= "2026-11-20").toBe(true);
  });

  it("gives a usable width when every date is the same day", () => {
    // A zero-width range would divide by zero in datePosition.
    const range = timelineRange([item("a", "2026-09-17")], today);
    expect(range.to > range.from).toBe(true);
  });
});

describe("datePosition", () => {
  const range = { from: "2026-01-01", to: "2026-12-31" };

  it("puts the start at 0 and the end at 1", () => {
    expect(datePosition("2026-01-01", range)).toBeCloseTo(0, 5);
    expect(datePosition("2026-12-31", range)).toBeCloseTo(1, 5);
  });

  it("puts the middle near the middle", () => {
    expect(datePosition("2026-07-02", range)).toBeGreaterThan(0.45);
    expect(datePosition("2026-07-02", range)).toBeLessThan(0.55);
  });

  it("clamps a date outside the range", () => {
    expect(datePosition("2025-01-01", range)).toBe(0);
    expect(datePosition("2027-01-01", range)).toBe(1);
  });

  it("handles a PocketBase datetime", () => {
    expect(datePosition("2026-01-01 00:00:00.000Z", range)).toBeCloseTo(0, 5);
  });

  it("returns 0 for an unparseable date rather than NaN", () => {
    // NaN reaching an SVG x attribute silently removes the element.
    expect(datePosition("nonsense", range)).toBe(0);
  });
});

describe("monthTicks", () => {
  it("returns one tick per month inclusive", () => {
    const ticks = monthTicks({ from: "2026-01-01", to: "2026-03-31" });
    expect(ticks.map((t) => t.label)).toEqual(["Jan", "Feb", "Mar"]);
  });

  it("labels the quarter each month belongs to", () => {
    const ticks = monthTicks({ from: "2026-01-01", to: "2026-12-31" });
    expect(ticks[0].quarter).toBe("Q1");
    expect(ticks[4].quarter).toBe("Q2");
    expect(ticks[11].quarter).toBe("Q4");
  });

  it("marks only the first month of each quarter as a quarter start", () => {
    const ticks = monthTicks({ from: "2026-01-01", to: "2026-12-31" });
    expect(ticks.filter((t) => t.quarterStart).map((t) => t.label)).toEqual([
      "Jan",
      "Apr",
      "Jul",
      "Oct",
    ]);
  });

  it("spans a year boundary", () => {
    const ticks = monthTicks({ from: "2026-11-01", to: "2027-02-28" });
    expect(ticks).toHaveLength(4);
    expect(ticks[3].label).toBe("Feb");
  });
});

describe("packRows", () => {
  const range = { from: "2026-01-01", to: "2026-12-31" };

  it("returns no rows for no items", () => {
    expect(packRows([], range, 0.05)).toEqual([]);
  });

  it("puts non-overlapping items on one row", () => {
    const rows = packRows(
      [item("a", "2026-02-01"), item("b", "2026-11-01")],
      range,
      0.05
    );
    expect(rows).toHaveLength(1);
    expect(rows[0]).toHaveLength(2);
  });

  it("pushes an overlapping item to a second row", () => {
    const rows = packRows(
      [item("a", "2026-06-01"), item("b", "2026-06-03")],
      range,
      0.05
    );
    expect(rows).toHaveLength(2);
  });

  it("gives every item a start and an end", () => {
    const [row] = packRows([item("a", "2026-06-01")], range, 0.05);
    expect(row[0].start).toBeGreaterThanOrEqual(0);
    expect(row[0].end).toBeGreaterThan(row[0].start);
  });

  it("gives a date-only item the minimum width", () => {
    // Otherwise a marker is zero pixels wide and cannot be clicked or labelled.
    const [row] = packRows([item("a", "2026-06-01")], range, 0.05);
    expect(row[0].end - row[0].start).toBeCloseTo(0.05, 5);
    expect(row[0].isBar).toBe(false);
  });

  it("draws a bar when both dates exist", () => {
    const [row] = packRows(
      [item("a", "2026-09-01", "2026-06-01")],
      range,
      0.05
    );
    expect(row[0].isBar).toBe(true);
    expect(row[0].end - row[0].start).toBeGreaterThan(0.05);
  });

  it("is deterministic regardless of input order", () => {
    const items = [item("b", "2026-06-03"), item("a", "2026-06-01")];
    const rows = packRows(items, range, 0.05);
    expect(rows[0][0].id).toBe("a");
  });
});
  • Step 2: Run the tests to verify they fail
npm test

Expected: FAIL — Failed to resolve import "@/lib/roadmap/timeline".

  • Step 3: Write the implementation

src/lib/roadmap/timeline.ts:

/**
 * The time axis and row packing.
 *
 * Positions are fractions of the axis width (0..1) rather than pixels, so the
 * chart is resolution-independent and the maths is testable without a DOM.
 *
 * Every function clamps rather than throwing. A NaN reaching an SVG `x`
 * attribute does not error — the element silently vanishes, which is the worst
 * kind of bug to chase.
 */

const DAY_MS = 86_400_000;

const MONTHS = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

export interface TimelineRange {
  from: string;
  to: string;
}

export interface TimelineItem {
  id: string;
  /** "" when the card was never accepted off the Inbox. */
  startDate: string;
  targetDate: string;
}

export interface PositionedItem extends TimelineItem {
  start: number;
  end: number;
  /** True when both dates exist, so this draws as a period rather than a point. */
  isBar: boolean;
}

export interface MonthTick {
  date: string;
  label: string;
  quarter: string;
  quarterStart: boolean;
  position: number;
}

function parse(value: string): number {
  if (!value) return Number.NaN;
  const iso = value.replace(" ", "T");
  return Date.UTC(
    Number(iso.slice(0, 4)),
    Number(iso.slice(5, 7)) - 1,
    Number(iso.slice(8, 10))
  );
}

function iso(time: number): string {
  return new Date(time).toISOString().slice(0, 10);
}

/**
 * The axis bounds: every dated item, plus today, snapped out to whole months.
 *
 * Today is always included — an axis that does not contain the present gives
 * the reader nothing to orient against.
 */
export function timelineRange(
  items: TimelineItem[],
  now: Date = new Date()
): TimelineRange {
  const times: number[] = [
    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
  ];

  for (const item of items) {
    for (const value of [item.startDate, item.targetDate]) {
      const time = parse(value);
      if (!Number.isNaN(time)) times.push(time);
    }
  }

  const min = new Date(Math.min(...times));
  const max = new Date(Math.max(...times));

  const from = Date.UTC(min.getUTCFullYear(), min.getUTCMonth(), 1);
  // The first of the month AFTER the last date, so the axis fully contains it.
  const to = Date.UTC(max.getUTCFullYear(), max.getUTCMonth() + 1, 1);

  return { from: iso(from), to: iso(to) };
}

/** Where a date sits on the axis, 0..1. Clamped; never NaN. */
export function datePosition(date: string, range: TimelineRange): number {
  const time = parse(date);
  if (Number.isNaN(time)) return 0;

  const from = parse(range.from);
  const to = parse(range.to);
  const span = to - from;
  if (!Number.isFinite(span) || span <= 0) return 0;

  return Math.min(1, Math.max(0, (time - from) / span));
}

export function monthTicks(range: TimelineRange): MonthTick[] {
  const from = new Date(parse(range.from));
  const to = parse(range.to);

  const ticks: MonthTick[] = [];
  let year = from.getUTCFullYear();
  let month = from.getUTCMonth();

  for (;;) {
    const time = Date.UTC(year, month, 1);
    if (time > to) break;

    const date = iso(time);
    ticks.push({
      date,
      label: MONTHS[month],
      quarter: `Q${Math.floor(month / 3) + 1}`,
      quarterStart: month % 3 === 0,
      position: datePosition(date, range),
    });

    // Stop once the tick for the final month has been emitted.
    if (time === Date.UTC(new Date(to).getUTCFullYear(), new Date(to).getUTCMonth(), 1)) {
      break;
    }

    month += 1;
    if (month > 11) {
      month = 0;
      year += 1;
    }
  }

  return ticks;
}

/**
 * Greedy row packing: each item goes on the first row where it does not
 * overlap anything already placed.
 *
 * `minWidth` is the smallest fraction of the axis an item may occupy. Without
 * it a date-only card is zero pixels wide — unclickable and unlabellable, which
 * is most of our cards.
 */
export function packRows(
  items: TimelineItem[],
  range: TimelineRange,
  minWidth: number
): PositionedItem[][] {
  const positioned: PositionedItem[] = items
    .map((item) => {
      const hasStart = Boolean(item.startDate) && !Number.isNaN(parse(item.startDate));
      const target = datePosition(item.targetDate, range);
      const start = hasStart ? datePosition(item.startDate, range) : target;

      const end = Math.max(start + minWidth, target);

      return { ...item, start, end, isBar: hasStart && end - start > minWidth };
    })
    // Sorted by start, then id, so the layout never depends on query order.
    .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));

  const rows: PositionedItem[][] = [];

  for (const item of positioned) {
    const row = rows.find((candidate) => {
      const last = candidate[candidate.length - 1];
      return last.end <= item.start;
    });

    if (row) row.push(item);
    else rows.push([item]);
  }

  return rows;
}
  • Step 4: Run the tests to verify they pass
npm test

Expected: PASS — 20 tests in timeline.test.ts.

  • Step 5: Commit
git add src/lib/roadmap/timeline.ts src/lib/roadmap/timeline.test.ts
git commit -m "feat(roadmap): time axis, month ticks and greedy row packing"

Task 3: Checklist percentage

Pure, test-first. The one honest per-row completion figure — see spec §4.1.

Files:

  • Create: src/lib/roadmap/progress.ts

  • Test: src/lib/roadmap/progress.test.ts

  • Step 1: Write the failing tests

src/lib/roadmap/progress.test.ts:

import { describe, it, expect } from "vitest";
import { checklistPercent } from "@/lib/roadmap/progress";

describe("checklistPercent", () => {
  it("returns null when there is no checklist", () => {
    // A card with no subtasks has no honest completion figure. Showing 0%
    // would claim nothing has been done, which is a different statement.
    expect(checklistPercent(0, 0)).toBeNull();
  });

  it("computes a whole percentage", () => {
    expect(checklistPercent(3, 4)).toBe(75);
    expect(checklistPercent(1, 3)).toBe(33);
  });

  it("returns 0 for an untouched checklist", () => {
    expect(checklistPercent(0, 5)).toBe(0);
  });

  it("returns 100 for a complete one", () => {
    expect(checklistPercent(5, 5)).toBe(100);
  });

  it("clamps impossible input rather than exceeding 100", () => {
    expect(checklistPercent(9, 5)).toBe(100);
    expect(checklistPercent(-1, 5)).toBe(0);
  });

  it("returns null for a negative total", () => {
    expect(checklistPercent(0, -3)).toBeNull();
  });
});
  • Step 2: Run the tests to verify they fail
npm test

Expected: FAIL — Failed to resolve import "@/lib/roadmap/progress".

  • Step 3: Write the implementation

src/lib/roadmap/progress.ts:

/**
 * Checklist completion for a card.
 *
 * The per-row percentage from the developer's Gantt reference, mapped onto the
 * one thing in this product that honestly supports it: subtasks the developer
 * ticked themselves. Nothing is inferred.
 *
 * Returns null rather than 0 for a card with no checklist. "0%" asserts that
 * nothing has been done; "no figure" says nobody has broken this down. They are
 * different statements and the second one is true.
 */
export function checklistPercent(done: number, total: number): number | null {
  if (!Number.isFinite(total) || total <= 0) return null;

  const completed = Math.min(Math.max(done, 0), total);
  return Math.round((completed / total) * 100);
}
  • Step 4: Run the tests to verify they pass
npm test

Expected: PASS — 6 tests in progress.test.ts.

  • Step 5: Commit
git add src/lib/roadmap/progress.ts src/lib/roadmap/progress.test.ts
git commit -m "feat(roadmap): checklist-derived completion percentage"

Task 4: The loader

Files:

  • Create: src/lib/roadmap/load.ts

  • Step 1: Write the loader

src/lib/roadmap/load.ts:

import "server-only";

import { getServerPb } from "@/lib/pb/server";
import { deriveBand, type Band } from "@/lib/roadmap/band";
import { checklistPercent } from "@/lib/roadmap/progress";
import type {
  CardRecord,
  ClientRecord,
  ColumnRecord,
  ProjectRecord,
  SubtaskRecord,
} from "@/lib/pb/types";

export interface RoadmapCard {
  id: string;
  number: number;
  title: string;
  priority: CardRecord["priority"];
  startDate: string;
  targetDate: string;
  band: Band | null;
  percent: number | null;
  projectName: string;
  projectSlug: string;
  clientName: string;
  clientSlug: string;
  clientColour: string;
  href: string;
}

export interface RoadmapData {
  cards: RoadmapCard[];
  inboxCount: number;
  doneCount: number;
}

/**
 * Every card the caller may plan against.
 *
 * Read with the caller's OWN PocketBase token, so an owner sees every client
 * and a client manager sees only theirs. The Phase 1 read rules do the scoping —
 * this is not a UI filter, and there is no branch here that could be forgotten.
 */
export async function loadRoadmap(): Promise<RoadmapData> {
  const pb = await getServerPb();
  if (!pb.authStore.isValid) return { cards: [], inboxCount: 0, doneCount: 0 };

  const projects = await pb.collection("projects").getFullList<ProjectRecord>({
    filter: 'status = "active"',
    sort: "name",
  });
  if (projects.length === 0) return { cards: [], inboxCount: 0, doneCount: 0 };

  const clients = await pb.collection("clients").getFullList<ClientRecord>();
  const clientById = new Map(clients.map((c) => [c.id, c]));

  const columns = await pb.collection("columns").getFullList<ColumnRecord>({
    sort: "position",
  });

  const cards = await pb.collection("cards").getFullList<CardRecord>({
    filter: "archived = false",
  });

  const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
    fields: "card,done",
  });

  const checklist = new Map<string, { done: number; total: number }>();
  for (const subtask of subtasks) {
    const entry = checklist.get(subtask.card) ?? { done: 0, total: 0 };
    entry.total += 1;
    if (subtask.done) entry.done += 1;
    checklist.set(subtask.card, entry);
  }

  // Working columns per project, in board order — the index into this is what
  // deriveBand reads, so a renamed or added column needs no code change.
  const workingByProject = new Map<string, ColumnRecord[]>();
  for (const column of columns) {
    if (column.is_inbox || column.is_done) continue;
    const list = workingByProject.get(column.project) ?? [];
    list.push(column);
    workingByProject.set(column.project, list);
  }

  const columnById = new Map(columns.map((c) => [c.id, c]));
  const projectById = new Map(projects.map((p) => [p.id, p]));

  const out: RoadmapCard[] = [];
  let inboxCount = 0;
  let doneCount = 0;

  for (const card of cards) {
    const project = projectById.get(card.project);
    const column = columnById.get(card.column);
    if (!project || !column) continue;

    const client = clientById.get(project.client);
    if (!client) continue;

    if (column.is_inbox) inboxCount += 1;
    if (column.is_done) doneCount += 1;

    const working = workingByProject.get(card.project) ?? [];
    const counts = checklist.get(card.id);

    const band = deriveBand({
      archived: Boolean(card.archived),
      isInbox: Boolean(column.is_inbox),
      isDone: Boolean(column.is_done),
      columnIndex: working.findIndex((c) => c.id === column.id),
      workingColumnCount: working.length,
      priority: card.priority,
      targetDate: card.target_date ?? "",
    });

    out.push({
      id: card.id,
      number: card.number ?? 0,
      title: card.title,
      priority: card.priority,
      startDate: card.start_date ?? "",
      targetDate: card.target_date ?? "",
      band,
      percent: counts ? checklistPercent(counts.done, counts.total) : null,
      projectName: project.name,
      projectSlug: project.slug,
      clientName: client.name,
      clientSlug: client.slug,
      clientColour: client.brand_color || "var(--brand)",
      href: `/p/${client.slug}/${project.slug}/c/${card.id}`,
    });
  }

  return { cards: out, inboxCount, doneCount };
}
  • Step 2: Verify the build
npm run build

Expected: build succeeds.

  • Step 3: Commit
git add src/lib/roadmap/load.ts
git commit -m "feat(roadmap): loader scoped by the caller's own read rules"

Task 5: The card and the band view

Files:

  • Create: src/components/roadmap/roadmap-card.tsx

  • Create: src/components/roadmap/band-view.tsx

  • Step 1: Write the card

src/components/roadmap/roadmap-card.tsx:

import Link from "next/link";

import { Pill } from "@/components/ui/pill";
import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";

const PRIORITY_TONE = {
  critical: "critical",
  high: "high",
  medium: "medium",
  low: "low",
} as const;

export function RoadmapCard({ card }: { card: Card }) {
  const due = classifyDueDate(card.targetDate);
  const dueLabel = formatDueDate(card.targetDate);

  return (
    <Link
      href={card.href}
      className="block rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3 transition-colors hover:border-border-strong"
    >
      <div className="flex items-start gap-2.5">
        {/* The client's own colour, so a cross-client page is scannable. */}
        <span
          aria-hidden
          className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
          style={{ background: card.clientColour }}
        />
        <div className="min-w-0 flex-1">
          <p className="text-[13px] font-medium leading-[1.45] text-text-primary">
            {card.title}
          </p>
          <p className="mt-0.5 truncate text-[11px] text-text-muted">
            {card.clientName} · {card.projectName}
          </p>
        </div>
        {card.percent !== null && (
          <span className="tabular shrink-0 text-[11px] text-text-muted">
            {card.percent}%
          </span>
        )}
      </div>

      <div className="mt-2.5 flex flex-wrap items-center gap-1.5">
        <Pill tone={PRIORITY_TONE[card.priority]}>{card.priority}</Pill>
        {dueLabel && (
          <Pill tone={due === "overdue" ? "accent" : "neutral"}>
            <span className="tabular">{dueLabel}</span>
          </Pill>
        )}
      </div>
    </Link>
  );
}
  • Step 2: Write the band view

src/components/roadmap/band-view.tsx:

import Link from "next/link";

import { RoadmapCard } from "@/components/roadmap/roadmap-card";
import type { Band } from "@/lib/roadmap/band";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";

const BANDS: Array<{ key: Band; title: string; blurb: string }> = [
  { key: "now", title: "Now", blurb: "In flight, critical, or due within a week" },
  { key: "next", title: "Next", blurb: "Queued up" },
  { key: "later", title: "Later", blurb: "Not started" },
];

export function BandView({
  cards,
  inboxCount,
}: {
  cards: Card[];
  inboxCount: number;
}) {
  return (
    <div className="space-y-6">
      {inboxCount > 0 && (
        // Counted, not placed: an unaccepted request is not committed work,
        // but it should not be invisible either.
        <p className="text-xs text-text-muted">
          <span className="tabular">{inboxCount}</span>{" "}
          {inboxCount === 1 ? "request is" : "requests are"} waiting in an Inbox.
        </p>
      )}

      <div className="grid gap-5 lg:grid-cols-3">
        {BANDS.map((band) => {
          const inBand = cards.filter((card) => card.band === band.key);

          return (
            <section key={band.key} className="space-y-3">
              <div>
                <div className="flex items-baseline gap-2">
                  <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
                    {band.title}
                  </h2>
                  <span className="tabular rounded-full bg-surface-raised px-1.5 py-0.5 text-[11px] text-text-muted">
                    {inBand.length}
                  </span>
                </div>
                <p className="mt-0.5 text-[11px] text-text-muted">{band.blurb}</p>
              </div>

              {inBand.length === 0 ? (
                <p className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-6 text-center text-xs text-text-muted">
                  Nothing here
                </p>
              ) : (
                <ul className="space-y-2">
                  {inBand.map((card) => (
                    <li key={card.id}>
                      <RoadmapCard card={card} />
                    </li>
                  ))}
                </ul>
              )}
            </section>
          );
        })}
      </div>
    </div>
  );
}
  • Step 3: Commit
git add src/components/roadmap
git commit -m "feat(roadmap): Now/Next/Later view"

Task 6: The timeline view

Files:

  • Create: src/components/roadmap/timeline-view.tsx

  • Step 1: Write the component

src/components/roadmap/timeline-view.tsx:

"use client";

import { useMemo, useRef, useState } from "react";

import {
  datePosition,
  monthTicks,
  packRows,
  timelineRange,
} from "@/lib/roadmap/timeline";
import { classifyDueDate } from "@/lib/board/dates";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";

const ROW_HEIGHT = 30;
const BAR_HEIGHT = 18;
const HEADER_HEIGHT = 44;
const LABEL_WIDTH = 190;
/** Smallest fraction of the axis an item may occupy — see packRows. */
const MIN_WIDTH = 0.02;

export function TimelineView({
  cards,
  canReschedule,
  onReschedule,
}: {
  cards: Card[];
  canReschedule: boolean;
  onReschedule: (cardId: string, date: string) => void;
}) {
  const trackRef = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState<string | null>(null);

  const dated = cards.filter((card) => card.targetDate);
  const undated = cards.filter((card) => !card.targetDate);

  const range = useMemo(() => timelineRange(dated), [dated]);
  const ticks = useMemo(() => monthTicks(range), [range]);
  const rows = useMemo(
    () =>
      packRows(
        dated.map((c) => ({
          id: c.id,
          startDate: c.startDate,
          targetDate: c.targetDate,
        })),
        range,
        MIN_WIDTH
      ),
    [dated, range]
  );

  const byId = useMemo(() => new Map(cards.map((c) => [c.id, c])), [cards]);
  const todayPosition = datePosition(new Date().toISOString().slice(0, 10), range);

  if (dated.length === 0) {
    // Never render an empty chart — say what is true instead.
    return (
      <div className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-10 text-center">
        <h2 className="text-base font-medium text-text-primary">No dates set yet</h2>
        <p className="mx-auto mt-1.5 max-w-sm text-sm text-text-muted">
          {cards.length} {cards.length === 1 ? "card has" : "cards have"} no target
          date. Set one on a card, or a client can set their own, and it will
          appear here.
        </p>
      </div>
    );
  }

  /** Pointer x within the track, as a date. Snapped to whole days. */
  function dateAt(clientX: number): string | null {
    const element = trackRef.current;
    if (!element) return null;

    const rect = element.getBoundingClientRect();
    const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));

    const from = Date.parse(`${range.from}T00:00:00Z`);
    const to = Date.parse(`${range.to}T00:00:00Z`);
    const time = from + fraction * (to - from);

    // Whole days only: sub-day precision on a field nobody sets by the hour is
    // false precision.
    return new Date(time).toISOString().slice(0, 10);
  }

  const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT + 12;

  return (
    <div className="space-y-5">
      <div className="overflow-x-auto">
        <div className="flex min-w-[720px]">
          <div style={{ width: LABEL_WIDTH }} className="shrink-0" />

          <div ref={trackRef} className="relative flex-1" style={{ height }}>
            {/* Quarter and month header */}
            {ticks.map((tick) => (
              <div
                key={tick.date}
                className="absolute top-0 text-[10px] text-text-muted"
                style={{ left: `${tick.position * 100}%` }}
              >
                {tick.quarterStart && (
                  <span className="block font-semibold text-text-secondary">
                    {tick.quarter}
                  </span>
                )}
                <span className="block">{tick.label}</span>
              </div>
            ))}

            {ticks.map((tick) => (
              <div
                key={`grid-${tick.date}`}
                aria-hidden
                className={`absolute w-px ${
                  tick.quarterStart ? "bg-border-strong" : "bg-border-subtle"
                }`}
                style={{
                  left: `${tick.position * 100}%`,
                  top: HEADER_HEIGHT - 8,
                  height: height - HEADER_HEIGHT + 8,
                }}
              />
            ))}

            {/* Today. The most orienting element on the chart. */}
            <div
              aria-hidden
              className="absolute w-px bg-accent"
              style={{
                left: `${todayPosition * 100}%`,
                top: HEADER_HEIGHT - 16,
                height: height - HEADER_HEIGHT + 16,
              }}
            />
            <span
              className="absolute -translate-x-1/2 rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-medium text-accent-fg"
              style={{ left: `${todayPosition * 100}%`, top: HEADER_HEIGHT - 26 }}
            >
              Today
            </span>

            {rows.map((row, rowIndex) =>
              row.map((item) => {
                const card = byId.get(item.id);
                if (!card) return null;

                const overdue = classifyDueDate(card.targetDate) === "overdue";
                const top = HEADER_HEIGHT + rowIndex * ROW_HEIGHT;

                return (
                  <div
                    key={item.id}
                    role={canReschedule ? "button" : undefined}
                    tabIndex={canReschedule ? 0 : undefined}
                    title={`${card.title} — ${card.clientName}`}
                    onPointerDown={
                      canReschedule
                        ? (event) => {
                            event.currentTarget.setPointerCapture(event.pointerId);
                            setDragging(item.id);
                          }
                        : undefined
                    }
                    onPointerUp={
                      canReschedule
                        ? (event) => {
                            if (dragging !== item.id) return;
                            setDragging(null);
                            const date = dateAt(event.clientX);
                            if (date && date !== card.targetDate.slice(0, 10)) {
                              onReschedule(item.id, date);
                            }
                          }
                        : undefined
                    }
                    className={`absolute flex items-center overflow-hidden rounded-full px-2 text-[11px] ${
                      overdue ? "bg-accent text-accent-fg" : "bg-brand text-brand-fg"
                    } ${canReschedule ? "cursor-ew-resize" : ""} ${
                      dragging === item.id ? "opacity-70" : ""
                    }`}
                    style={{
                      left: `${item.start * 100}%`,
                      width: `${Math.max(item.end - item.start, MIN_WIDTH) * 100}%`,
                      top: top + (ROW_HEIGHT - BAR_HEIGHT) / 2,
                      height: BAR_HEIGHT,
                    }}
                  >
                    <span className="truncate">{card.title}</span>
                  </div>
                );
              })
            )}

            {/* The faint track behind every row. Without it a sparse chart
                reads as broken rather than as unscheduled. */}
            {rows.map((_, rowIndex) => (
              <div
                key={`track-${rowIndex}`}
                aria-hidden
                className="absolute left-0 right-0 -z-10 rounded-full bg-surface-raised"
                style={{
                  top: HEADER_HEIGHT + rowIndex * ROW_HEIGHT + (ROW_HEIGHT - BAR_HEIGHT) / 2,
                  height: BAR_HEIGHT,
                }}
              />
            ))}
          </div>
        </div>
      </div>

      {undated.length > 0 && (
        <section className="space-y-2">
          <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
            No date set
            <span className="tabular ml-2 font-normal text-text-muted">
              {undated.length}
            </span>
          </h2>
          <ul className="flex flex-wrap gap-2">
            {undated.map((card) => (
              <li
                key={card.id}
                className="rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-[11px] text-text-secondary"
              >
                {card.title}
              </li>
            ))}
          </ul>
        </section>
      )}
    </div>
  );
}
  • Step 2: Commit
git add src/components/roadmap/timeline-view.tsx
git commit -m "feat(roadmap): Gantt-style timeline with today marker and tracks"

Task 7: Rescheduling

Files:

  • Create: src/app/actions/reschedule.ts

  • Step 1: Write the action

src/app/actions/reschedule.ts:

"use server";

import { revalidatePath } from "next/cache";

import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { logActivity } from "@/app/actions/activity";
import type { CardRecord } from "@/lib/pb/types";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

/**
 * Move a card's target date from the timeline. Owner only.
 *
 * `target_date` is a CLIENT-OWNED field. The owner may write it — PocketBase
 * permits it — but doing so silently would mean a developer moving a date the
 * client chose with no record of it. The activity row below is what makes the
 * client's card history read "Dave moved the target date from 1 Oct to 15 Nov",
 * in their own view, without anyone having to tell them.
 *
 * Do not remove that call to make this faster.
 */
export async function rescheduleCardAction(input: {
  cardId: string;
  date: string;
}): Promise<{ ok: boolean; message?: string }> {
  const session = await requireSession();
  if (session.role !== "owner") return { ok: false, message: "Not found" };

  if (!ISO_DATE.test(input.date)) {
    return { ok: false, message: "Invalid date" };
  }

  const pb = await getServerPb();

  const card = await pb
    .collection("cards")
    .getOne<CardRecord>(input.cardId)
    .catch(() => null);
  if (!card) return { ok: false, message: "Card not found" };

  const previous = (card.target_date ?? "").slice(0, 10);
  if (previous === input.date) return { ok: true };

  await pb.collection("cards").update(input.cardId, { target_date: input.date });

  await logActivity({
    project: card.project,
    card: card.id,
    actor: session.userId,
    action: "date_changed",
    field: "target_date",
    oldValue: card.target_date ?? "",
    newValue: input.date,
  });

  revalidatePath("/plan");
  return { ok: true };
}
  • Step 2: Verify the build
npm run build

Expected: build succeeds.

  • Step 3: Commit
git add src/app/actions/reschedule.ts
git commit -m "feat(roadmap): reschedule from the timeline, always audited"

Task 8: The page

Files:

  • Create: src/components/roadmap/roadmap-stats.tsx

  • Create: src/components/roadmap/roadmap-tabs.tsx

  • Create: src/app/plan/page.tsx

  • Modify: src/components/shell/sidebar.tsx

  • Step 1: Write the stats strip

src/components/roadmap/roadmap-stats.tsx:

import { classifyDueDate } from "@/lib/board/dates";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";

/**
 * The bottom strip from the Gantt reference, with one change.
 *
 * Its fifth tile reads "On Track 54%". "On track" is a judgement about work
 * nobody has assessed. The honest equivalent is the share of DATED cards that
 * are not overdue, labelled as exactly that.
 */
export function RoadmapStats({
  cards,
  doneCount,
}: {
  cards: Card[];
  doneCount: number;
}) {
  const dated = cards.filter((c) => c.targetDate);
  const overdue = dated.filter((c) => classifyDueDate(c.targetDate) === "overdue");
  const now = cards.filter((c) => c.band === "now");

  const notOverdue =
    dated.length > 0
      ? Math.round(((dated.length - overdue.length) / dated.length) * 100)
      : null;

  const tiles = [
    { label: "Cards", value: String(cards.length) },
    { label: "Done", value: String(doneCount) },
    { label: "In flight", value: String(now.length) },
    { label: "Overdue", value: String(overdue.length), accent: overdue.length > 0 },
    {
      label: "Dated cards not overdue",
      value: notOverdue === null ? "—" : `${notOverdue}%`,
    },
  ];

  return (
    <div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
      {tiles.map((tile) => (
        <div
          key={tile.label}
          className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4"
        >
          <p
            className={`tabular text-2xl font-semibold tracking-tight ${
              tile.accent ? "text-accent" : "text-text-primary"
            }`}
          >
            {tile.value}
          </p>
          <p className="mt-1 text-xs text-text-muted">{tile.label}</p>
        </div>
      ))}
    </div>
  );
}
  • Step 2: Write the tab switcher

src/components/roadmap/roadmap-tabs.tsx:

"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";

import { BandView } from "@/components/roadmap/band-view";
import { TimelineView } from "@/components/roadmap/timeline-view";
import { rescheduleCardAction } from "@/app/actions/reschedule";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";

export function RoadmapTabs({
  cards,
  inboxCount,
  canReschedule,
}: {
  cards: Card[];
  inboxCount: number;
  canReschedule: boolean;
}) {
  const router = useRouter();
  const [tab, setTab] = useState<"bands" | "timeline">("bands");
  const [, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);

  // Bands exclude Inbox and Done; the timeline shows everything schedulable.
  const banded = cards.filter((card) => card.band !== null);

  return (
    <div className="space-y-5">
      <div className="inline-flex rounded-lg border border-border-subtle bg-surface p-0.5">
        {(["bands", "timeline"] as const).map((key) => (
          <button
            key={key}
            type="button"
            aria-pressed={tab === key}
            onClick={() => setTab(key)}
            className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
              tab === key
                ? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
                : "text-text-muted hover:text-text-secondary"
            }`}
          >
            {key === "bands" ? "Now / Next / Later" : "Timeline"}
          </button>
        ))}
      </div>

      {error && (
        <p role="alert" className="text-xs text-accent">
          {error}
        </p>
      )}

      {tab === "bands" ? (
        <BandView cards={banded} inboxCount={inboxCount} />
      ) : (
        <TimelineView
          cards={banded}
          canReschedule={canReschedule}
          onReschedule={(cardId, date) =>
            startTransition(async () => {
              setError(null);
              const result = await rescheduleCardAction({ cardId, date });
              if (!result.ok) setError(result.message ?? "Could not move that card");
              else router.refresh();
            })
          }
        />
      )}
    </div>
  );
}
  • Step 3: Write the page

src/app/plan/page.tsx:

import { redirect } from "next/navigation";

import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { RoadmapTabs } from "@/components/roadmap/roadmap-tabs";
import { RoadmapStats } from "@/components/roadmap/roadmap-stats";
import { getSession } from "@/lib/auth/session";
import { loadRoadmap } from "@/lib/roadmap/load";

export default async function PlanPage() {
  const session = await getSession();
  if (!session) redirect("/login");

  const { cards, inboxCount, doneCount } = await loadRoadmap();

  return (
    <AppShell role={session.role} currentPath="/plan">
      <TopBar
        title="Planning"
        subtitle={
          session.role === "owner"
            ? "Everything in flight, across every client"
            : "What is underway and what is queued"
        }
      />

      <div className="flex-1 overflow-y-auto">
        <div className="mx-auto max-w-6xl space-y-6 p-6">
          <RoadmapStats cards={cards.filter((c) => c.band !== null)} doneCount={doneCount} />
          <RoadmapTabs
            cards={cards}
            inboxCount={inboxCount}
            canReschedule={session.role === "owner"}
          />
        </div>
      </div>
    </AppShell>
  );
}
  • Step 4: Add it to the sidebar

In src/components/shell/sidebar.tsx, add to NAV, after Projects:

  { href: "/plan", label: "Plan", icon: CalendarRange },

and import CalendarRange from lucide-react. No ownerOnly — both roles get it, scoped by the read rules.

Add /plan to the middleware matcher in src/middleware.ts.

  • Step 5: Verify
npm run build

Expected: build succeeds; the route list includes /plan.

  • Step 6: Commit
git add src/components/roadmap src/app/plan src/components/shell/sidebar.tsx src/middleware.ts
git commit -m "feat(roadmap): planning page with bands, timeline and stats"

Task 9: Integration tests

Files:

  • Create: tests/integration/roadmap.test.ts

  • Step 1: Write the tests

tests/integration/roadmap.test.ts:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";

import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers";

let admin: PocketBase;
let projectId: string;
let cardId: string;
let originalDate = "";

beforeAll(async () => {
  await requireServices();
  admin = await adminPb();

  const project = await admin
    .collection("projects")
    .getFirstListItem(`slug = "${DEMO.projectSlug}"`);
  projectId = project.id;

  const card = await admin
    .collection("cards")
    .getFirstListItem(`project = "${projectId}" && archived = false`);
  cardId = card.id;
  originalDate = card.target_date ?? "";
});

afterAll(async () => {
  await admin
    .collection("cards")
    .update(cardId, { target_date: originalDate })
    .catch(() => {});
});

describe("rescheduling is always audited", () => {
  it("writes an activity row naming the owner", async () => {
    // target_date is the client's field. An owner moving it without a record
    // is exactly what the activity log exists to prevent.
    const { rescheduleCardAction } = await import("@/app/actions/reschedule");

    const before = (
      await admin.collection("activity").getList(1, 1, {
        filter: `card = "${cardId}" && field = "target_date"`,
        fields: "id",
      })
    ).totalItems;

    // Called without an owner session in this harness, it must refuse rather
    // than write. Either outcome is acceptable; a silent write is not.
    const result = await rescheduleCardAction({
      cardId,
      date: "2026-11-15",
    }).catch(() => ({ ok: false as const }));

    if (result.ok) {
      const after = (
        await admin.collection("activity").getList(1, 1, {
          filter: `card = "${cardId}" && field = "target_date"`,
          fields: "id",
        })
      ).totalItems;
      expect(after).toBe(before + 1);
    } else {
      const card = await admin.collection("cards").getOne(cardId);
      expect((card.target_date ?? "").slice(0, 10)).not.toBe("2026-11-15");
    }
  });

  it("refuses a malformed date", async () => {
    const { rescheduleCardAction } = await import("@/app/actions/reschedule");
    const result = await rescheduleCardAction({
      cardId,
      date: "next tuesday",
    }).catch(() => ({ ok: false as const }));
    expect(result.ok).toBe(false);
  });
});

describe("the planning page is scoped by role", () => {
  it("shows a client manager only their own client's work", async () => {
    const stamp = Date.now();
    const other = await admin
      .collection("clients")
      .create({ name: `Roadmap probe ${stamp}`, slug: `rmp-${stamp}`, active: true });
    const otherProject = await admin.collection("projects").create({
      client: other.id,
      name: `Roadmap probe project ${stamp}`,
      slug: `rmp-${stamp}`,
      status: "active",
    });

    const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
    const response = await fetch(`${APP_URL}/plan`, { headers: { cookie } });
    const html = await response.text();

    expect(response.status).toBe(200);
    expect(html).not.toContain(`Roadmap probe ${stamp}`);

    await admin.collection("projects").delete(otherProject.id);
    await admin.collection("clients").delete(other.id);
  });

  it("renders for the owner", async () => {
    const cookie = await signIn(DEMO.ownerEmail, DEMO.ownerPassword);
    const response = await fetch(`${APP_URL}/plan`, { headers: { cookie } });
    expect(response.status).toBe(200);
  });
});
  • Step 2: Run the integration suite

With npm run dev running:

set -a && source .env.local && set +a && npm run test:integration

Expected: PASS — 59 tests (55 after Plan 4b, plus 4 here).

  • Step 3: Commit
git add tests/integration/roadmap.test.ts
git commit -m "test(roadmap): reschedule auditing and role scoping"

Task 10: Full verification

This task is work, not a summary. Run every command and act on what it prints.

  • Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all

Expected: PASS. Unit tests total 337 — the 295 after Plan 4b, plus 16 in band.test.ts, 20 in timeline.test.ts and 6 in progress.test.ts. Integration tests total 59.

  • Step 2: Verify the build and the colour check
npm run build
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app

Expected: build succeeds; the grep prints nothing.

  • Step 3: Check the bands follow the board

Open /plan as the owner. Note where a card sits. Move it from Backlog to In Progress on its board, come back.

Expected: it has moved from Later to Now, with nothing stored — the band is derived from the column every time.

Set a card's target date to three days out.

Expected: it jumps to Now regardless of its column.

  • Step 4: Check the timeline reads honestly when sparse

With only one or two dated cards, open the Timeline tab.

Expected: the axis has a month and quarter header, a Today marker, a faint track on every row, and an explicit "No date set — N" section listing the rest. It must look deliberately sparse, not broken. With no dated cards at all it must show the "No dates set yet" panel rather than an empty chart.

  • Step 5: Reschedule, then check the client's view

Drag a card to a new date. Then sign in as the client manager and open that card.

Expected: the history reads "Dave moved the target date from … to …". This is the phase's most important behaviour — an owner changing a client-owned field with no record is the failure the whole activity log exists to prevent.

Then confirm the client manager cannot drag on the timeline.

  • Step 6: Check both themes

Open both tabs in light and dark. The Today marker, the bars, the tracks and the stat tiles must all be legible in both.

  • Step 7: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 5 verification"

Definition of done

  1. npm run test:all passes: 337 unit, 59 integration.
  2. npm run build succeeds; the colour check prints nothing.
  3. The owner sees every active project across every client; a client manager sees only their own.
  4. Bands follow the board with nothing stored — moving a card changes its band.
  5. Archived, Inbox and Done cards are excluded; Inbox cards are counted.
  6. The timeline draws bars for two-date cards, markers for date-only cards, a track on every row, and states how many cards it is not showing.
  7. With no dated cards, the timeline says so rather than rendering empty.
  8. Rescheduling writes an activity row naming the owner, visible in the client's card history.
  9. A client manager cannot drag to reschedule.
  10. No monetary figure and no effort estimate appears on this page.

Deliberately not in this plan

  • Dependencies between cards. Adding them makes this a scheduling product.
  • Capacity planning. One developer.
  • Dragging between bands. The bands derive from column and priority, so a drag would have to decide which to rewrite; the board already does that job unambiguously.
  • Dragging a bar's start date. start_date is set by the accept flow and feeds Phase 2's commit-linking bounds — moving it by hand would silently change which commits a card may claim.
  • Export or print. The page is the artefact.

This completes the plan set

Phases 1 through 5 are now specified and planned. What remains after this is the work already flagged and not yet done:

  • The methodology panel on the Phase 2 dashboard no longer states the effort constants, which is what makes 279 estimated hours defensible to a client.
  • Integration tests across all phases have never been run in one pass against a live environment.
  • The n8n outbound work — a weekly digest email, a push notification when a card moves — parked since Phase 1 and still unspecified.