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>
57 KiB
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
npm run test:allpasses: 337 unit, 59 integration.npm run buildsucceeds; the colour check prints nothing.- The owner sees every active project across every client; a client manager sees only their own.
- Bands follow the board with nothing stored — moving a card changes its band.
- Archived, Inbox and Done cards are excluded; Inbox cards are counted.
- 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.
- With no dated cards, the timeline says so rather than rendering empty.
- Rescheduling writes an activity row naming the owner, visible in the client's card history.
- A client manager cannot drag to reschedule.
- 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_dateis 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.