diff --git a/docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md b/docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md new file mode 100644 index 0000000..8528045 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md @@ -0,0 +1,1921 @@ +# 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`: + +```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 { + 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** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/roadmap/band"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/roadmap/band.ts`: + +```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** + +```bash +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** + +```bash +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`: + +```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** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/roadmap/timeline"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/roadmap/timeline.ts`: + +```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** + +```bash +npm test +``` + +Expected: PASS — 20 tests in `timeline.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +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`: + +```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** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/roadmap/progress"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/roadmap/progress.ts`: + +```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** + +```bash +npm test +``` + +Expected: PASS — 6 tests in `progress.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +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`: + +```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 { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return { cards: [], inboxCount: 0, doneCount: 0 }; + + const projects = await pb.collection("projects").getFullList({ + filter: 'status = "active"', + sort: "name", + }); + if (projects.length === 0) return { cards: [], inboxCount: 0, doneCount: 0 }; + + const clients = await pb.collection("clients").getFullList(); + const clientById = new Map(clients.map((c) => [c.id, c])); + + const columns = await pb.collection("columns").getFullList({ + sort: "position", + }); + + const cards = await pb.collection("cards").getFullList({ + filter: "archived = false", + }); + + const subtasks = await pb.collection("subtasks").getFullList({ + fields: "card,done", + }); + + const checklist = new Map(); + 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(); + 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** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +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`: + +```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 ( + +
+ {/* The client's own colour, so a cross-client page is scannable. */} + +
+

+ {card.title} +

+

+ {card.clientName} · {card.projectName} +

+
+ {card.percent !== null && ( + + {card.percent}% + + )} +
+ +
+ {card.priority} + {dueLabel && ( + + {dueLabel} + + )} +
+ + ); +} +``` + +- [ ] **Step 2: Write the band view** + +`src/components/roadmap/band-view.tsx`: + +```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 ( +
+ {inboxCount > 0 && ( + // Counted, not placed: an unaccepted request is not committed work, + // but it should not be invisible either. +

+ {inboxCount}{" "} + {inboxCount === 1 ? "request is" : "requests are"} waiting in an Inbox. +

+ )} + +
+ {BANDS.map((band) => { + const inBand = cards.filter((card) => card.band === band.key); + + return ( +
+
+
+

+ {band.title} +

+ + {inBand.length} + +
+

{band.blurb}

+
+ + {inBand.length === 0 ? ( +

+ Nothing here +

+ ) : ( +
    + {inBand.map((card) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + })} +
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +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`: + +```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(null); + const [dragging, setDragging] = useState(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 ( +
+

No dates set yet

+

+ {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. +

+
+ ); + } + + /** 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 ( +
+
+
+
+ +
+ {/* Quarter and month header */} + {ticks.map((tick) => ( +
+ {tick.quarterStart && ( + + {tick.quarter} + + )} + {tick.label} +
+ ))} + + {ticks.map((tick) => ( +
+ ))} + + {/* Today. The most orienting element on the chart. */} +
+ + Today + + + {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 ( +
{ + 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, + }} + > + {card.title} +
+ ); + }) + )} + + {/* The faint track behind every row. Without it a sparse chart + reads as broken rather than as unscheduled. */} + {rows.map((_, rowIndex) => ( +
+ ))} +
+
+
+ + {undated.length > 0 && ( +
+

+ No date set + + {undated.length} + +

+
    + {undated.map((card) => ( +
  • + {card.title} +
  • + ))} +
+
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +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`: + +```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(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** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +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`: + +```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 ( +
+ {tiles.map((tile) => ( +
+

+ {tile.value} +

+

{tile.label}

+
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: Write the tab switcher** + +`src/components/roadmap/roadmap-tabs.tsx`: + +```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(null); + + // Bands exclude Inbox and Done; the timeline shows everything schedulable. + const banded = cards.filter((card) => card.band !== null); + + return ( +
+
+ {(["bands", "timeline"] as const).map((key) => ( + + ))} +
+ + {error && ( +

+ {error} +

+ )} + + {tab === "bands" ? ( + + ) : ( + + startTransition(async () => { + setError(null); + const result = await rescheduleCardAction({ cardId, date }); + if (!result.ok) setError(result.message ?? "Could not move that card"); + else router.refresh(); + }) + } + /> + )} +
+ ); +} +``` + +- [ ] **Step 3: Write the page** + +`src/app/plan/page.tsx`: + +```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 ( + + + +
+
+ c.band !== null)} doneCount={doneCount} /> + +
+
+
+ ); +} +``` + +- [ ] **Step 4: Add it to the sidebar** + +In `src/components/shell/sidebar.tsx`, add to `NAV`, after Projects: + +```tsx + { 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** + +```bash +npm run build +``` + +Expected: build succeeds; the route list includes `/plan`. + +- [ ] **Step 6: Commit** + +```bash +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`: + +```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: + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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. diff --git a/docs/superpowers/specs/2026-09-17-phase-5-planning-and-roadmap-design.md b/docs/superpowers/specs/2026-09-17-phase-5-planning-and-roadmap-design.md new file mode 100644 index 0000000..4b3861d --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-phase-5-planning-and-roadmap-design.md @@ -0,0 +1,229 @@ +# Phase 5 — The Planning and Roadmap View + +**Date:** 2026-09-17 +**Status:** Draft for review +**Phase:** 5 of 5 — the last +**Depends on:** Phase 1 (cards, priorities, dates) + +--- + +## 1. Purpose + +A planning page that answers two different questions with two views: + +- **"What do I work on first?"** — every project's committed work, across every + client, grouped into Now / Next / Later. This is the default. +- **"When is this landing?"** — a real time axis for the cards that carry dates, + with an explicit lane for everything that does not. + +For a client manager it is the same page scoped to their own projects: what is +underway, what is queued, and when the dated items are expected. + +This is the "planning page with timelines" from the developer's original +description, adjusted for what the data actually supports — see §2. + +## 2. What the data actually supports + +Measured on the live database before writing this: + +``` +live cards: 5 + with a target_date: 1 + with a start_date: 0 + with BOTH (a real Gantt bar): 0 +``` + +Demo data, but the shape is structural rather than incidental: + +- `target_date` is **client-owned and optional**. Most cards will never get one, + because most cards are the developer's own work and nobody sets a date on them. +- `start_date` is only written when a card is **accepted off the Inbox** + (Plan 1c). Cards the developer creates directly never have one. + +So a conventional Gantt — a bar from start to finish — has almost nothing to +draw. **A timeline alone would look like a broken feature.** + +Hence two views. Now / Next / Later works with zero dates and is always useful; +the timeline earns its place as clients start setting dates, and says plainly how +many cards it is not showing. + +## 3. Now / Next / Later + +Bands are **derived, never stored**. A stored band is a third thing to keep in +sync with the column and the priority, and it would silently drift from both. + +Derivation, first match wins: + +1. **Excluded entirely** — archived cards, cards in the Inbox (an unaccepted + request is not committed work), and cards in the Done column. +2. **Now** — overdue, or due within seven days. An imminent date outranks where + the card sits on the board. +3. **Now** — the card is in the later half of the working columns. On the default + board that is In Progress and Review. +4. **Now** — critical priority. +5. **Next** — the card is in the second working column (To Do), or its priority + is high. +6. **Later** — everything else. + +Using the *position* of a column rather than its name means a renamed or added +column still works. The board's own order is the signal; the roadmap reads it +rather than keeping a parallel opinion. + +**Inbox cards are counted but not placed.** The page shows "3 requests waiting" +with a link, so pending work is visible without implying it is scheduled. + +## 4. The timeline + +A horizontal time axis across the dated cards, with an **undated lane** beside +it holding everything else. + +### 4.1 The reference, and what it can and cannot give us + +The developer supplied an annual Gantt template as the target look. Handled the +same way as the Hermes build log in Phase 2 — adopted in one list, corrected in +another. + +**Adopted:** + +| From the reference | Why it works here | +|---|---| +| **Quarter header with month subdivisions** | Right granularity for work measured in weeks | +| **A "Today" marker** — vertical line with a pill label | The single most orienting element on the chart, and trivial to draw | +| **Rows grouped by category, with a coloured left accent** | Becomes **grouped by project**, accented with the client's brand colour — exactly what a cross-client roadmap needs | +| **A faint full-width track behind every bar** | Load-bearing here rather than decorative — see below | +| **Rounded bars with circular end caps and an inline range label** | Reads as a period rather than a block | +| **A KPI strip along the bottom** | Reuses the tile pattern already built in Phase 2c | +| **A per-row completion percentage** | We *do* have an honest source for this — see below | + +**The completion percentage is the best idea in that template**, because it maps +onto something real: a card's **checklist progress** from Plan 1c. Four subtasks +with three ticked is genuinely 75%, computed from the developer's own ticks and +not from anything inferred. A card with no subtasks shows no percentage rather +than a fabricated one. + +**What the reference cannot give us:** + +Every task in that template has a start *and* an end, which is why it looks +dense and confident. Per §2, almost none of our cards will. Copying it literally +produces a chart of empty rows. + +So the **faint track is doing real work**: every row draws its full-width track +whether or not it has dates, and a date-only card places a marker on it. A +sparse chart then reads as *"these are the items, most are not scheduled yet"* +rather than as a rendering failure. That is the one adaptation that makes the +reference survive contact with this data. + +### 4.2 Drawing rules + +- A card with both `start_date` and `target_date` draws a **bar**, with its range + labelled inline as in the reference. +- A card with only `target_date` draws a **marker** on its track. This will be + most of them. +- A card with neither appears in the **undated lane**, which states its own size: + "11 cards with no date set". +- Overdue items take the warm accent — the same one used on the board and the + dashboard, and one of its three sanctioned uses. +- Rows are grouped by project and packed so nothing overlaps, with a minimum + width per item so short-dated cards stay clickable and labelled. + +### 4.3 The bottom strip + +Four tiles, each computable from data that exists: **total cards**, **done**, +**in progress**, and **overdue**. + +The reference's fifth tile is "On Track 54%". The honest equivalent is +**dated cards not overdue** — and it must be labelled as exactly that, because +"on track" implies a judgement about work nobody has assessed. + +**No effort estimate appears anywhere on this page.** Phase 2's hours are +project-level; there is no per-card duration to draw, and inventing a bar width +from one would repeat the mistake §2 exists to avoid. + +## 5. Drag to reschedule + +The owner can drag a card along the timeline to change its `target_date`. + +**This overwrites a field the client owns**, which makes the audit trail +load-bearing rather than incidental: + +- The change writes an `activity` row with `date_changed`, the **owner** as + actor, and the real old and new values — the same shape Plan 1a writes for + client edits. +- The client's card history therefore reads *"Dave moved the target date from + 1 Oct to 15 Nov"*, in their own view, without anyone having to tell them. + +A developer silently moving a date the client chose, with no record, is exactly +the failure the activity log exists to prevent. The log has always recorded +client-originated writes; this phase is the first time the owner routinely +changes a client-owned field, so it must record those too. + +Dragging is **owner-only**. A client manager reads the timeline and edits dates +where they already can — on the board tile and the card page, through +`/api/client/cards/:id`. + +Dragging snaps to whole days. Sub-day precision on a field nobody sets by the +hour is false precision. + +## 6. Scope + +| Role | Sees | +|---|---| +| Owner | Every active project, every client, on one page | +| Client manager | Their own client's projects only | + +Scoping is the Phase 1 read rules doing their job, not a UI filter — the loader +reads with the caller's own PocketBase token, as every other page does. + +Cross-client is the point for the owner: *"priorities on what I need to work on +first or last"* is a question that spans clients. Each card carries its client's +brand colour so the page is scannable by customer. + +## 7. Data model + +**No new fields.** Bands are derived, the timeline reads `start_date` and +`target_date`, and rescheduling writes `target_date` and an `activity` row — +all of which exist. + +This is worth stating because the instinct is to add a `roadmap_order` or a +stored band. Both would be a second source of truth for something the board +already decides. + +## 8. Out of scope + +- **Per-card effort or duration bars.** §4. +- **Dependencies between cards.** No card knows about another, and adding that + is a scheduling product, not a progress board. +- **Capacity or resource planning.** One developer. +- **Reordering priority by dragging between bands.** The bands are derived from + column and priority, so dragging between them would have to decide which of + those to rewrite — and the board already does that job unambiguously. +- **A client-facing export or PDF.** The page is the artefact. + +## 9. Acceptance criteria + +1. The owner sees every active project's committed work on one page, grouped + Now / Next / Later, with each card showing its client and project. +2. A client manager sees only their own client's cards. +3. Archived, Inbox and Done cards are excluded from the bands; Inbox cards are + counted and linked. +4. Bands follow the board: moving a card from Backlog to In Progress moves it to + Now without any stored value changing. +5. The timeline draws bars for cards with both dates, markers for date-only + cards, and states how many cards it is not showing. Every row draws its + track whether or not it is scheduled, so a sparse chart reads as sparse + rather than broken. +6. A card with subtasks shows its checklist percentage; a card without shows + none rather than a fabricated one. +7. The bottom strip's fifth figure is labelled "dated cards not overdue", never + "on track". +8. The owner can drag a card to a new date; a client manager cannot. +9. A rescheduled card writes an `activity` row naming the owner, with correct + old and new values, visible in the client's card history. +10. With no dated cards at all, the timeline tab says so plainly rather than + rendering an empty chart. + +## 10. Plan + +One plan — `docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md`. +This phase adds no schema, no provider integration and no new security boundary; +splitting it would cost more in coordination than it saves in review.