Any signed-in user — owner or client_manager — can now edit dates directly on the Gantt. Three interaction shapes: - Drag the bar body to move it (both start and target shift by the same number of days, snapped to whole UTC days). - Drag the right edge to resize (target only). - Double-click any card or subtask bar to open a modal with Start + End date inputs, ESC to close, Enter to save. Both card and subtask rows ship the same affordances. Cards go through the existing rescheduleCardAction so the activity-log row keeps recording who moved the date; subtasks go through a new setSubtaskDatesAction (no audit row — matches the existing subtask-toggle pattern). Components touched: - GanttBar gains canEdit / onMove / onResize / onEditRequest. - GanttDateEditor is the inline two-input modal. - ProjectSection forwards four callbacks (card move/resize, subtask move/resize) plus onEditRequest. - GanttPlanView holds the editor's open state and computes one shared timeline range across sections. - GanttClient wraps the view and bridges to the actions. - /plan/page.tsx renders GanttClient. Spec §6 amended to move drag from out-of-scope into the delivered surface. 482/482 unit tests pass (was 471, +11 from bar-math). Integration plan-gantt tests still pass. Build clean.
15 KiB
Phase 10 — Gantt plan redesign
Date: 2026-09-21
Status: Draft for review
Phase: 10
Depends on: Phase 5 (planning and roadmap view), the existing cards / subtasks schema
Supersedes: The Puzzle + Timeline tabs of the Phase 5 /plan page. The data they read (start_date, target_date on cards) is reused; only the view and a small schema add ship here.
1. Purpose
Replace the current /plan screen — its Now/Next/Later grouping and its client-grouped timeline do not work for the developer — with a real Gantt where each row is one of the developer's own work items, indented subtasks sit under it, and every bar represents a duration.
The inspiration is a reference Gantt (dark navy palette, monthly header above weekly ticks, parent bars in a saturated tone with their subtasks in a lighter tint). The reference is treated as layout, colour direction, and shape only — none of its domain language ("Planning / Packaging / Marketing") is carried over. What ships is keyed to the existing projects → cards → subtasks tree.
The result answers two questions the current page answers badly:
- "How long will this take, and how does it sit against the rest of the work?" — every card bar shows duration.
- "What does this card actually involve?" — the subtasks under it are visible without leaving the planning page.
2. Data — the only schema add
Two new fields, both optional and idempotent through ensureXxxFields-style helpers:
subtasks.start_date (date, optional)
subtasks.target_date (date, optional)
The existing subtasks collection is currently { card, title, done, position, created, updated } and acts as the checklist on the card detail page. The new date fields extend that record so the same row can both be a checklist item and appear on the Gantt when it has dates. A subtask with neither date set is invisible on the Gantt, exactly like the Phase 5 timeline's "undated lane" treatment — except here we count them once per project section instead of giving them a row.
cards.assignee (text, optional, max ~120)
A free-text label rendered above the parent bar. Empty by default; nothing in the rest of the app reads it. The Phase 5 timeline never had this and the reference image's "Bob Johnson / Henry Leblanc" labels read as human assignments, so the field exists to support that look. One developer means it will usually be empty in practice; that is intentional, not underbuilt.
Everything else — cards.start_date, cards.target_date, cards.title, cards.project, the existing subtasks.card relation, the existing (card, position) index — is reused without change.
No new collections. No new rules. The Phase 1 read rules already scope a client manager to their own client's work; that carries over as-is.
3. The new /plan page
3.1 Vertical structure
For every active project, in client-then-project alphabetical order, render one project section with this shape:
┌──────────────────────────────────────────────────────────────────────┐
│ ● Project Name Client · 12 cards │ ← section header
├──────────────────────────────────────────────────────────────────────┤
│ MONTHS January February │ ← month row
│ WEEKS W1 W2 W3 W4 W1 W2 W3 W4 W1 W2 W3 W4 │ ← week row
├───────────────────────────────────┬──────────────────────────────────┤
│ Card title │ ████████ │ ← card row
│ Subtask one │ ████ │ ← subtask row
│ Subtask two │ ████ │ ← subtask row
│ Card title without dates │ — not scheduled — │ ← undated card row
│ Card title │ ████ │ ← card row
├───────────────────────────────────┴──────────────────────────────────┤
│ 2 cards not scheduled · 3 subtasks not scheduled [Add dates] │ ← footer
└──────────────────────────────────────────────────────────────────────┘
Two or more project sections stack vertically. A horizontal divider separates them.
3.2 Inside one project section
One flat row array drives both the left label column and the right timeline track, indexed identically. That is the same invariant the existing TimelineView already relies on (one row array, two views) — it guarantees a row's label and its bar cannot drift, and it makes subtask indentation a matter of CSS padding rather than a parallel data structure. The array entries are:
{ kind: "card", card, datedSubtasks: Subtask[] }— renders the parent bar fromcard.start_date → card.target_dateand one indented subtask bar per dated subtask.{ kind: "undated", card }— renders the undated card row. Position below the dated rows within the same project section.
datedSubtasks is pre-filtered at loader time to subtasks where start_date or target_date is set; subtasks without dates are counted into the section footer instead.
3.3 The bars
- Parent card bar:
bg-client-brandwith the client's brand colour (already onclients.brand_color, today rendered as the row dot). Rounded ends, same height across all cards (12 px). If the card has no assignee, no label is drawn above the bar. - Subtask bars: same colour, opacity 0.5 (a single Tailwind opacity modifier against the parent's coloured background; avoids a second CSS variable per project). Inset to the right of the parent bar by
padding-left: 1.5rem. - Card bar without dates: not drawn. The label column shows an em-dash and the text "not scheduled".
3.4 Header
The header above the timeline reuses monthTicks and weekTicks from src/lib/roadmap/timeline.ts. Month label sits centred above the weeks it spans. Each W1..W4 segment is one quarter-month. The today marker (vertical line + "Today" pill) is reused unchanged.
3.5 Section header
The project section header is a single row above the timeline header inside the same panel:
- A 2 px coloured bar at the top edge in the project client's brand colour (this becomes the section's only visual brand cue and removes the need for a separate icon column).
- The project name in larger type on the left.
- The client name + active card count on the right.
3.6 Empty and sparse cases
- No active projects at all — the page shows the existing "No active projects" tile. Same wording as before so it doesn't read as a new feature.
- No dated cards and no dated subtasks — the page shows a single "Nothing scheduled yet" panel with the same copy as Phase 5 §4.2. We do not invent an undated lane for the Gantt.
- A section with only undated cards — section still renders, timeline area collapses to a single row per undated card with "not scheduled" text. The section footer counts them.
3.7 Interactions (this phase)
Read-only.
- Clicking a card bar → opens
/p/[client]/[project]/c/[id](existing). - Clicking a subtask bar → opens the parent card; the existing card detail page renders subtasks, and the subtask with
start_date/target_dateset gets a small "scheduled" indicator next to its checkbox (text only, no Gantt duplicated on the card page). - Hovering a bar shows a tooltip with the card title, the client, and the explicit date range (
"5 Mar → 19 Mar"). - Keyboard: every bar is a
role="button"withtabIndex=0, focusable. Enter / Space opens the card. The focus ring uses the existing--brandtoken so it reads against the dark background.
Editing dates happens on the card detail page or the subtask detail panel, as today. Drag-to-reschedule is out of scope for this phase — see §6.
4. Loader
loadRoadmap is replaced by a new loadPlan returning the structure the view needs in one server-side pass:
type PlanSection = {
project: { id: string; name: string; slug: string };
client: { id: string; name: string; slug: string; brandColor: string };
cards: PlanCard[];
};
type PlanCard = {
id: string;
title: string;
priority: CardPriority;
startDate: string | null;
targetDate: string | null;
assignee: string;
href: string;
datedSubtasks: PlanSubtask[];
undatedSubtaskCount: number;
};
type PlanSubtask = {
id: string;
title: string;
done: boolean;
startDate: string | null;
targetDate: string | null;
};
Implementation notes:
- One PocketBase trip per collection (
projects,clients,cards,subtasks), in the same shape asloadRoadmaptoday. - Sort: cards within a project by
start_dateASC NULLS LAST, then bytarget_dateASC NULLS LAST, then by title. Subtasks within a card bypositionASC. - Scope: read with the caller's own PocketBase token. The Phase 1 read rules already constrain a client manager to their own client's projects.
datedSubtasksis filtered to rows where either date is present; the rest are counted.
The existing RoadmapCard type and src/lib/roadmap/band.ts are dropped from the /plan page but kept (used by the dashboard tile if applicable — to confirm before deletion in the plan). The new loader does not need band.
5. Files touched
scripts/setup-remote.ts— addensureSubtaskDateFields(addsstart_date,target_date) andensureCardAssigneeField(addsassignee). Both follow theensureXxxFieldsidempotent pattern (Phase 8cbackground-md,Phase 9aproposal fields).src/lib/roadmap/load.ts— addloadPlan()alongsideloadRoadmap, or renameloadRoadmap→loadPlanand update callers if the dashboard tile does not need the legacy band/shape. Choice deferred to the implementation plan.src/app/plan/page.tsx— replace theRoadmapTabsbody withGanttPlanView. Keep theTopBartitle "Planning".src/components/roadmap/gantt-plan-view.tsx— new. Single section component; does the heavy lifting (row array, row alignment, month/week ticks via existing helpers).src/components/roadmap/project-section.tsx— new. One project's section, including its header.src/components/roadmap/gantt-bar.tsx— new. One bar (card or subtask variant).- Tests:
src/lib/roadmap/load.test.tsfor the loader;tests/plan.test.tsfor the page integration.
6. Out of scope
- Drag-to-reschedule — moved into scope after first build. Any signed-in user (owner or client_manager) can drag a bar to move it or drag its right edge to resize it. Writes go through the existing
rescheduleCardAction(cards) or a newsetSubtaskDatesAction(subtasks). Cards still get theactivityaudit row; subtasks do not, matching the existingdone-toggle pattern. - Filtering by client, priority, or status. Not in the request.
- Connector lines between parent and child bars (the reference Gantt has them; the indented alignment reads cleanly without them, and the dark palette makes 1px rules tricky to get right).
- Hover-only tooltips that pop out of the bar (we use a native
titleattribute for now). - Removing the legacy
RoadmapTabs/TimelineView/PuzzleView/band-viewfiles. If the dashboard still uses them, leave them. If not, the plan may delete them. This keeps the diff small and reversible. - Inline date editor when the page is read-only. Owners AND client managers get the editor (it's the Gantt's whole purpose for them). If a future role needs read-only access, pass
canEdit={false}toGanttPlanView.
7. Migration
ensureSubtaskDateFields and ensureCardAssigneeField are idempotent. Re-running npm run setup-remote over the live instance adds the fields without affecting existing rows. No backfill: existing rows simply show as unscheduled on the new page, exactly the way Phase 5's "undated lane" handled the same case.
8. Acceptance criteria
- The
/planpage loads for the owner and shows one section per active project, sorted client-then-project alphabetical. - A client manager sees only their own client's project sections.
- Within a section, dated cards render as parent bars coloured with the client's brand colour. Their dated subtasks render as indented lighter-tint bars below them.
- A card with an
assigneeshows that label above its bar; a card without one shows nothing above the bar. - Cards without dates show as "not scheduled" rows in the same section, below the dated rows, and are counted in the section footer.
- Subtasks without dates are invisible on the Gantt but counted in the section footer.
- The header shows month labels and
W1..W4segments using the existingmonthTicks/weekTickshelpers. A vertical "Today" line + pill marks the current date. - Clicking a card or subtask bar opens the existing card detail page.
- Every bar is keyboard-focusable and activates on Enter / Space.
- With no dated cards or subtasks across any project, the page shows a single "Nothing scheduled yet" panel.
- With no active projects at all, the page shows the existing "No active projects" tile.
- The schema additions survive a re-run of
scripts/setup-remote.tswithout duplicating fields. - The existing
/p/[client]/[project]/c/[id]page still renders subtasks correctly when a subtask has dates set. - Unit tests cover the new loader shape. Integration tests cover the page rendering against the demo data set.
9. Risks
- Background colour contrast. Subtasks use
opacity-50against the project's brand colour, which is already opaque. If the brand colour is light (e.g.#ffffffor close to it), the subtask bar may be hard to read. Mitigation: a follow-up phase can clamp luminance or use a derived darker tint per project; not worth blocking Phase 10 on. - Wide projects. A project with many dated cards and subtasks becomes a long vertical list. Mitigated by the existing
overflow-x-autoon the timeline track; the label column stays the same width. No change in this phase. - Subtasks as both checklist and planning. A subtask with
done = truebut atarget_datein the future shows as a completed-but-scheduled row. We draw it with a strikethrough on its title. No rule changes; the user can choose to ignore either signal.