From d88dc91d7df33a8aeb557b936331d87d9843b035 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 17 Sep 2026 07:14:47 +0200 Subject: [PATCH] docs: Phase 5 spec and plan for the planning and roadmap view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...2026-09-17-phase-5-planning-and-roadmap.md | 1921 +++++++++++++++++ ...-17-phase-5-planning-and-roadmap-design.md | 229 ++ 2 files changed, 2150 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-17-phase-5-planning-and-roadmap.md create mode 100644 docs/superpowers/specs/2026-09-17-phase-5-planning-and-roadmap-design.md 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.