# Project Plan View

- **Date:** 2026-09-29
- **Status:** Complete
- **Status Date:** 2026-09-30
- **Phases:** 7
- **Phases Complete:** 6
- **Notes:** A read-only Gantt of project delivery dates, reached from the Projects nav
  dropdown below Project List. Visibility is the project list's rule exactly — company
  scoping plus `is_deleted = false` — and the flags are the dashboard's own, read from the
  same admin configuration rather than a second set of rules. The one thing the system does
  not have yet is a **start date**: projects carry no static date columns at all, and the
  dashboard config names only an ECD and a completion date. Phase 1 adds a `start` role
  beside them. **All phases complete and tested, 2026-09-30.** The prototype settled the
  design over three rounds on 2026-09-29; testing the built page against real data the next
  day amended several of its answers — see **Amended in build and testing**, and in
  particular 20, which is the one that changed how a parent reads.

## Plan Phases

0. ✅ Prototype — settle the layout in `www/test.php`
1. ✅ Schema & admin — the `start` date role (migration 111)
2. ✅ Shared flag helpers — lift `dashboardFlags()` into `global_functions.php`
3. ✅ Endpoint — `project_plan_load.php`
4. ✅ Route, nav and page shell
5. ✅ The chart — `project_plan.js` / `project_plan.css`
6. ✅ Documentation

## Problem

A project's delivery dates are visible one project at a time. The project list shows a row
per project and the editor shows one project's fields, so the only way to answer "what is
being delivered over the next six months, and what is slipping" is to read the list and
hold the dates in your head. The dashboard answers "what needs attention today" but it is a
queue, not a plan — it tells you a project is fourteen days overdue without showing you
what that does to the two phases behind it.

What is wanted is a plan view: one row per project, bars on a time axis, scrollable
backwards and forwards through time and up and down through volume, with the dashboard's
existing flags on the rows so the same project is called overdue in both places for the
same reason.

### What the system already has

- **Visibility.** `data_get.php` serves the project list from `projects.vw_projects_list`
  under one rule: logged in, company 1 sees everything, everyone else sees rows matching
  their `company_id`. Deleted projects never appear because migration 061 filters
  `is_deleted` inside the view. The route itself is gated on module `projects` /
  permission `projects` in `fn/routes.php`. There is no record-level permission check on
  the list, and the plan view must not add one — "the same restrictions the map and list
  have" means exactly these.
- **Flags.** `dashboardFlagRegistry()` in `global_functions.php` defines four flags —
  `ecd_overdue` (Overdue), `ecd_moving` (Risk), `ecd_due` (Check), `stale` (Check) — each
  with a badge, a severity, a priority and its thresholds. `dashboardFlags()` in
  `dashboard_load.php` runs them, and it already takes a module filter, so
  `dashboardFlags($dbh, $userID, $companyID, $scope, ['projects'])` is the whole flag query.
- **Status colour.** `projects.project_status.project_status_style` holds a key from
  `statusStyleRegistry()`, never a colour. The editor header badge and the dashboard stage
  bar both resolve it through that registry; the plan's bars will too, so all three move
  together when an admin restyles a status.
- **Hierarchy.** `vw_projects_list` carries `parent_project_id`, `parent_project_name`,
  `top_project_id` and `top_project_name`. The root of a lineage is one value shared by
  every project in the tree, which is what makes a lineage groupable in one pass.

### What it does not have

**A start date.** `$staticFields` in `project_save.php` is `parent_project_id`,
`project_name`, `project_status_id`, `project_manager`, `geom`, the created/modified
columns, `company_id` and `account_id`. There is no date column on `projects.projects` at
all — every business date a client keeps is a dynamic EAV field in
`projects.project_field_values_date`, and the only thing that names one of them for a
purpose is `public.dashboard_config`, which allows three roles: `ecd`, `completion` and
`complete_status`.

So a bar has a right-hand edge and no left-hand edge. Phase 1 fixes that at the source
rather than in the plan view, because "which field means the start" is the same class of
client decision as "which field means the ECD" and belongs in the same place.

## Decisions

Settled with Dave, 2026-09-29.

1. **The bar's start comes from a new `start` role in the dashboard configuration**, not
   from `created_datetime` and not from a per-user picker on the view. `created_datetime`
   is a record-keeping date — a project raised in January and built in September would draw
   eight months of bar that mean nothing. A per-view picker would let two people read the
   same chart differently, and the point of a plan is that everyone sees the same plan. The
   role sits in Module Management → Projects → Dashboard beside ECD and Completion, and
   every module gets it, not just projects.
2. **Rows are grouped by lineage root and collapsible.** Reworded 2026-09-29: this said
   "by programme", and **GeoLynx has no programme**. What is grouped on is
   `top_project_id`, the root of a containment lineage, and that root is a *project* — it
   has a boundary, premises, a status, a manager and its own dates. Borrowing a Gantt's
   word for a collapsible parent row smuggled in a concept that does not exist. A real
   programme sits *over* a type or a group of types and need not follow the boundary
   hierarchy at all. See Decision 19.
3. **Bar hue is the project status's style token; flags are chips on the row label.** Two
   facts, two channels. Colouring the bar by flag severity would make an unflagged project
   a colourless bar and would throw away the stage reading entirely.
4. **Read-only.** Clicking a bar opens the project editor. No save path, no journal
   entries, no write-permission question, and rescheduling stays where the audit trail
   already works. Drag-to-reschedule is a later feature if it is wanted at all; it needs
   undo, conflict handling and snap-to-day before it is safe.
5. **Scope applies to the projects and the flags together.** The dashboard's All/Mine
   toggle is offered here too, and both queries take it. Showing every project but only
   your own flags would silently drop chips from rows that have them.

### Settled by the prototype

Phase 0 ran over three rounds with Dave on 2026-09-29. Every question it existed to answer
is below; nothing in this section is still open.

6. **Design 2 — bar plus slip.** One row per project, a bar from start to ECD, and a slip
   segment where the ECD has passed. **A project carrying only one date is excluded and
   counted**, not drawn as a milestone diamond: the milestone-tolerant design was built,
   compared and rejected. The consequence has to be stated on the page, because it is real
   — roughly one row in nine in the 250-project scenario — and because it means the plan's
   row count deliberately does not reconcile with the project list's.

7. **Slip is two marks, not one.** *Realised* runs ECD → actual completion: it finished,
   late by a known amount, and that amount never changes again. *Running* runs ECD → today:
   it has not finished, and it is one day longer tomorrow. They are told apart by the
   right-hand end — a hard cap when it is over, an open arrow while it runs — reinforced by
   hatch direction, `////` realised and `\\\\` running. The end cap is the primary read,
   because direction is weak at a 12px bar. A project that finished **early** gets no slip;
   its completion diamond sits inside its own bar, which says so without a mark of its own.

8. **Slip takes the flag's severity, not its own.** Built briefly in amber on the argument
   that red was overloaded — today line, impossible dates and slip — and reverted, because
   the row already carries a red Overdue chip from `dashboardFlagRegistry()` and an amber
   bar beside it stated one fact at two severities. Consistency with the shared flag
   definition beats the colour budget. What makes red workable is that the impossible-dates
   bar stopped being hatched in the same change, so red now appears as three structurally
   different marks: a dashed vertical line, a hatched horizontal bar, and a solid bar with a
   border and a warning glyph. The accepted cost is that a late Build and a late Design look
   alike past the ECD; the stage reading comes from the bar to the left of the seam.

   A tidy split fell out of it and is worth keeping: **amber means the date moved, red means
   the date passed.**

9. **Baseline: the original ECD.** A grey hairline back to the date first promised, capped
   at it. The data needs no schema change — `dashboardFlagEcdMoving()` already reads the ECD
   history to count moves for the Risk flag, so the original value is derivable today.
   Nothing else in GeoLynx shows a date that has been moved; the dashboard can only count
   it. A per-move tick variant was built and is not the default: five ticks on a 12px bar is
   noise at compact density.

10. **Horizon: full span, with an overview strip.** The axis covers the whole data extent
    and scrolls. Above it sits a compressed histogram of the entire estate with a draggable
    viewport box, so "is there anything out in 2029" is answerable without scrolling there.
    Windowed pagination was built and rejected — it cuts bars at the page edge and drops
    rows with no work in the window, and a scrollbar thumb at 11% is navigable where a paged
    view loses continuity.

11. **Premises: a number in the label.** `valid_premises`, right-aligned. A label mini-bar
    and a bar-height encoding were both built and rejected — height is unreadable at 12px,
    fights the summary bracket, and reads as *importance* rather than size; the mini-bar
    read well but cost 34px the label column turned out not to have.

    A parent shows **its own count and its lineage total**, and the gap between them is the
    point: 180 of its own against 4,120 in the lineage says the work is in the children.
    `is_valid` means "this project is the lowest level in its lineage holding this UPRN", so
    a parent's own count excludes everything its children hold — which is the only way the
    numbers sum correctly. CLAUDE.md states the rule; this is where it bites.

12. **A delivery strip, premises landing per period.** A footer stuck to the bottom of the
    same scroller as the rows, on the same x-axis, so a spike lines up with the projects
    causing it. Premises land on the **actual** finish date where there is one, otherwise
    the ECD — so the strip moves when a date slips, which is the whole point: a project
    slipping thirty days stops being a bar that moved and becomes N premises leaving this
    quarter. Buckets follow the zoom. Delivery is inherently lumpy because premises land on
    a single date; finer buckets make it lumpier, and that is the truth about how a build
    lands rather than a rendering fault.

    This is the thing standard project-management software cannot do, and the reason is
    structural: a task has no intrinsic size, so a Gantt plots duration because duration is
    all there is. A GeoLynx project has premises. Time is the right axis and the wrong
    measure.

13. **The overview strip and the delivery strip both stay, and stay different.** The
    overview counts **projects** across the whole extent and exists for navigation; the
    delivery footer counts **premises** at the visible scale and exists for reading. The
    same period can be 7× different resolution between them. Collapsing them into one was
    considered and rejected — only the footer can say "this month's spike is those three
    projects", and keeping the units different is what stops them reading as one chart drawn
    twice.

14. **Defaults: week zoom, compact density.** Both switchable by the user. Two-tier header
    at every zoom, the coarse tier naming something the fine tier cannot — month over weeks,
    quarter over months, year over quarters — and the coarse label pins left so the period
    you are inside stays named after you scroll past its boundary.

15. **Rows sort by start date ascending**, within a group and between groups, with name as
    the tie-break and a one-date row sorting at the date it has. A Gantt is legible when the
    bars form a diagonal cascade. This deliberately differs from the project list's
    `modified_datetime desc`: the list answers "recently touched", the plan answers "when
    does it happen".

16. **The label column is 360px at compact**, and its flag chips collapse to the
    highest-priority badge plus `+N`, with the rest on hover. The arithmetic forces it —
    three chips is ~180px at `.68rem`, and with the chevron, the group count and the
    premises number there is nothing left for the name, which is the one thing the column
    exists to show. `+N` is the same bargain `.dash-more` already makes on the dashboard
    queue. Comfortable shows every chip with its metric; dense drops to severity dots.

17. **A collapsed group's summary bar takes in its children's dates.** Members' span, not
    the parent's own dates and not both. It is also the only one of the three that survives
    a grouping whose header is not a project, which matters for Decision 19. Accepted
    consequence: a parent's own dates are not drawn, so **a late parent's slip never appears
    as a length** — only as a chip and in the tooltip. Recoverable later by switching to the
    both-overlaid summary without new code.

18. **Filters in v1 are fixed, on columns the view already carries.** All/Mine per Decision
    5, plus **status** (multi-select, which also supplies the style tokens) and **account**.
    Admin-configurable filterable fields are deferred to v2 for three concrete reasons:
    `public.dashboard_config` is `PRIMARY KEY (module, role)` so it holds one row per role
    and cannot carry a list; **nothing in the codebase filters a list by a dynamic field
    value** — `data_get.php` has zero references to the EAV value tables — so each filter
    would be a type-dispatched join across five typed tables plus option fetching; and the
    admin control is a new type, not a copy of `dateSelect()`.

19. **Programmes are deferred, and nothing is built for them now.** A programme is an
    organising layer *above* projects that need not follow the boundary hierarchy; the
    nearest thing available today is a nominated dynamic field, which would want its own
    config role and its own admin control. Both were prototyped and are explicitly out of
    scope.

    One thing to preserve so it is not accidentally designed out: the grouping abstraction
    must allow a **header that is not a project** — no dates, no status, nothing to open.
    The prototype proved the shape (a group whose `top` is null, with the summary degrading
    to the members' span, which is why Decision 17 is the one that survives). In the build
    that means **`project_plan_load.php` returns a grouping key and label per row** rather
    than the client deriving groups from `top_project_id`, so a future programme source is
    an endpoint change and not a client rewrite.

### Amended in build and testing

Settled with Dave on 2026-09-30, testing the built page against real project data.
Everything here overrides the Phase 0 answer it names.

20. **A group header draws the parent's own dates AND the lineage's, as two marks.**
    Amends 17. The bracket spanned the whole lineage, so a parent whose child ran past it
    appeared to finish when the *child* did — while its premises were counted, correctly,
    on its own finish date. The number was right and the bar was what lied.

    ```
    Parent: [========= own =========]//// extended ////]
    Child :            [                               ]
    ```

    The parent's own start-to-ECD is a solid bar in its status colour; the part of the
    span that exists only because a child reaches beyond it is grey with a **vertical**
    hatch. Red at 45° and 135° already mean "finished late" and "late and running", and
    this is neither — it is not lateness, it is somebody else's time — so it differs in
    both hue and direction. A parent with no dates of its own draws the whole span grey,
    which is the honest reading.

    This also settles Decision 17's accepted consequence: a late parent's slip *is* now
    visible as a length, because its own bar is drawn.

21. **A lineage root that cannot be drawn is still a project.** Three separate bugs traced
    to one cause — Decision 6 excludes a one-date project, but Decision 2 makes the root a
    structural row, so a root with only one date arrived as a header with no row behind it.
    It was labelled "a grouping, not a project", it could not be clicked, and it had no
    status colour. It now keeps its name, stays clickable, carries its status, and says in
    the tooltip which date it is missing. "A grouping, not a project" is now reachable only
    where there is genuinely no project behind the header — the programme shape, which
    nothing produces today.

    The root's status is carried on **every row of its group** by the endpoint, not read
    from the root's own row, precisely because the root often has no row.

22. **The excluded counts open a modal, not the project list.** Amends 6. The project list
    is organised around premises volumes and cannot show which date a project is missing,
    so the one thing you followed the link to do was the one thing it could not help with.
    Each count is now a link to a list of the actual records, with both date columns and
    the missing one called out, each name linking into its editor. Capped at 200.

23. **The twisty toggles, the name opens.** A lineage root is a project in its own right
    and has to be enterable from the plan exactly as a child is; the whole label used to
    toggle, which made the row you most often want to open the one row you could not.

24. **Filters live behind a toggle, and the status filter is checkboxes.** Amends 18's
    toolbar. A multi-select tall enough to be usable set the height of the whole bar and
    left the single-line controls stranded at the bottom of it. Filters are occasional
    where zoom and density are constant, so they get their own panel, closed by default.
    Checkboxes rather than a dropdown: the panel is hidden anyway, so its height is free,
    and no popup layer has to fight the sticky chart header for z-index.

25. **The scope toggle is the dashboard's own control**, `.dash-seg` with `data-scope`, not
    a Bootstrap button group. This route already loads `dashboard.css`. Everything defaults
    to Everything rather than Mine, which is the one deliberate difference: the dashboard
    answers "what needs me today", a plan answers "what is being delivered".

26. **The delivery rule, stated and made traceable.** A project contributes its own
    `valid_premises` to exactly one bucket — the one containing its **finish date**, its
    actual completion where it has one and its ECD otherwise. Nothing is spread across the
    months a project runs through, so a project merely passing over a month contributes
    nothing to it. A parent contributes on its own finish date independently of its
    children, and never double counts them because `valid_premises` is the `is_valid`
    count. Only drawn projects are counted.

    None of that was checkable without arithmetic, so each bar now names its contributors
    on hover and the footer states how many projects are not counted at all.

27. **A mark must never depend on a gradient alone, and never on a CSS custom property.**
    Two rendering faults with one shape. The slip put its pale tone inside the gradient, so
    at a 12px bar it was two faint stripes on nothing and a late project read as bar, gap,
    diamond; it now has a solid base fill with a denser hatch and an outline over it. The
    group bracket took its colour through a `--span-col` property, needed only because
    `::before`/`::after` caps cannot be styled inline; the caps are now real elements and
    every colour is set directly. Neither failure is visible without a browser, and this
    codebase has no browser to check in.

28. **Every request carries a token and late replies are dropped.** The flag request is the
    slow one, so a reply arriving after a scope or filter change painting the previous
    view's chips onto the new rows was the normal case, not a rare one.

## Build

### Phase 0 — Prototype (`www/test.php`)

`www/test.php` is the design prototype scratch page; the connection canvas is the current
occupant and goes back into git history as the others did. Everything on the page is fake —
no server calls, no database — so the ugly cases can be switched between instantly.

A design switcher across the top, a scenario switcher beside it, and the four notes below
the canvas (**Fits / Reads / Cost / Watch**), matching the existing convention.

**Designs to compare**

1. **Rows with a bar** — one row per project, bar from start to ECD, collapsible lineage
   headers with a summary bar. The baseline. *(Written before the wording was corrected —
   "programme" throughout this Phase 0 section means the root of a lineage, which is a
   project. See Decision 2.)*
2. **Rows with a bar plus a slip tail** — as above, but an overdue project's bar continues
   past its ECD to today in a second tone, so slip has a length rather than a badge.
3. **Milestone-tolerant rows** — bars where both dates exist, a diamond where only one
   does, so nothing dated is missing from the chart.

**Scenarios (the ugly cases)**

1. **Three-deep programme, dates disagreeing** — a child whose ECD is past its parent's,
   and a grandchild that starts before its parent does. The collapsed-summary question.
2. **Forty phases under one programme** — does the collapse/expand model hold, and what
   does a collapsed forty-member bar say?
3. **Mixed durations** — a four-year programme and a three-day task on the same axis.
4. **250 projects, mixed hierarchy** — scroll performance, and whether the label column is
   still readable at the row height that needs.
5. **Every flag at once** — Overdue, Risk and Check on one row, with metrics.
6. **Bad data** — a start after its ECD, a completion before its start, a project whose
   only date is the ECD.
7. **Long names** — a 90-character project name in the label column.
8. **Unconfigured** — no dashboard config for projects at all. What the page says, and who
   it points at.

**What the prototype is not.** It is not the implementation and no file from it ships as
is. It is three arguments about what the implementation should be.

**✅ Complete, 2026-09-29.** It ran over three rounds and grew past the three designs and
eight scenarios above: a ninth scenario for the long-horizon question, a tenth for
grouping, and switchable controls for horizon, baseline, premises, delivery and grouping
as each question came up. Six designs' worth of ideas were built and four rejected on
sight — milestone-tolerant rows, windowed pagination, bar height by premises, and the
label mini-bar. Every answer is in **Decisions** above, 6 through 19.

Four things the prototype found that were not on anyone's list:

- **"Programme" was the wrong word** and it was in Decision 2. GeoLynx has no programme;
  what was being grouped is a lineage root, which is a project.
- **Slip is two facts, not one.** The first round drew one tail to today and gave a project
  that finished late no tail at all — only a completion diamond floating past the end of
  its bar, saying nothing about the overrun.
- **Baseline is free.** `dashboardFlagEcdMoving()` already reads the ECD history, so the
  originally-promised date needs no schema change to draw.
- **A Gantt measures the wrong quantity for this domain.** Which is Decision 12, and the
  most consequential thing to come out of Phase 0.

### Phase 1 — Schema & admin: the `start` date role  ✅

**Migration `db/111_dashboard_start_date_role.sql`.** Two constraints on
`public.dashboard_config` change, and nothing else:

- `dashboard_config_role_ck` widens from `role IN ('ecd', 'completion', 'complete_status')`
  to include `'start'`.
- `dashboard_config_target_ck` widens its field-pointer branch from
  `role IN ('ecd', 'completion')` to `role IN ('start', 'ecd', 'completion')`, so a `start`
  row carries a `field_id` and no `status_id` like the other two date roles.

Guards at the top: the table exists, and the role check does not already allow `'start'`
(re-run protection). Verify block at the bottom: insert a `start` row into a temp
constraint test, or simply assert the new constraint definition contains `start` — the
migration must not be able to report OK having changed nothing.

**No data is seeded.** Which field means the start is an admin decision and the admin
screen owns it; a migration that picked one would be config in a migration.

**`getDashboardConfig()`** — add `'start' => null` to the per-module skeleton. The loop
below it already assigns `$cache[$module][$r['role']] = dashboardFieldRow(...)` generically,
so the read side needs nothing else.

**`admin_load.php::dashboardConfigLoad()`** — return `start_field_id` beside
`ecd_field_id` and `completion_field_id`.

**`admin_load.php::moduleOverview()`** — `$dashState` is "configured means at least one of
the three settings is set". Add `start` to that test, or a module configured with only a
start date reads as unconfigured on the admin matrix.

**`admin_save.php::dashboardConfigSave()`** — parse `start_field_id`, validate it against
`getDashboardDateFieldOptions()` like the other two, and insert the `start` row. The
"must be different fields" check currently compares ECD against completion; extend it to
all three pairs — a start and an ECD pointing at the same field draws a zero-length bar and
means somebody has misread the screen.

**`admin_dashboard.js`** — one more `dateSelect()` above the ECD one, `start_field_id` in
the save payload, `start_field_id` in the dirty-state and `configured` checks. The label is
**Start date**, its help text says it is the date a bar begins on the project plan.

Nothing on the dashboard itself reads the start role. It is configuration the plan view
consumes, added here because this is where date roles live.

### Phase 2 — Shared flag helpers  ✅

`dashboardFlags()` and its builders live in `dashboard_load.php`. The plan endpoint needs
them, and a helper defined in one endpoint and called from another is a fatal that only
shows up in the browser — so they move to `global_functions.php`, which every endpoint
includes and where `dashboardFlagRegistry()` already lives.

Moved, not copied (one definition, no drift):

- `dashboardFlags()`
- `dashboardVisibleModules()`
- `dashboardActiveSql()`, `dashboardCompanySql()`, `dashboardMineSql()`
- `dashboardNameColumn()`, `dashboardHref()`
- `dashboardFlagRow()`, `dashboardDayDiff()`, `dashboardNetDays()`
- the four builders: `dashboardFlagEcdOverdue()`, `dashboardFlagEcdDue()`,
  `dashboardFlagEcdMoving()`, `dashboardFlagStale()`

`dashboardCurrentSql()` **stays** in `dashboard_load.php` — only its widget modes call it,
and it reaches `dashboardActiveSql()` through `global_functions.php` like everything else.

The registry's own comment says "The builders named in `build` stay in
`dashboard_load.php` — nothing else calls them." That stops being true here; update it in
the same change rather than leaving a comment that contradicts the code.

All of these are pure functions taking `$dbh` and scalars — no request state, no output —
so the lift is mechanical. The risk is a missed call site, which is why it is its own phase
with its own test: **the dashboard must be verified unchanged before the plan view is
built on top of it.**

### Phase 3 — Endpoint: `www/fn/project_plan_load.php`  ✅

One endpoint, one mode. Header, error suppression, `login_check`, then:

```php
requireModuleAccess($pdo, 'projects', 'projects', 'read');
```

Reuses `$pdo` rather than opening its own handle, like `dashboard_load.php` does.

**Configuration first.** Read `getDashboardConfig($dbh)['projects']`. If neither `start`
nor `ecd` is configured there is no chart to draw: return
`['success' => true, 'configured' => false]` plus the reason, and let the page render an
explanation pointing at Module Management → Projects → Dashboard. Not an error — an
unconfigured module is a normal state, the same call the dashboard makes when it falls
through to recently-opened.

**The rows.** `projects.vw_projects_list` for the names, status, parent, top project,
manager and account — it is the list page's own source, so visibility cannot drift between
the two pages — joined to the configured dates through `dashboardDateExpr('projects',
$fieldRow, 'p')` against `projects.projects p`. The view already excludes deleted projects
(061), so no `is_deleted` predicate is needed or possible here.

Scoping, matching `data_get.php` exactly:

```sql
AND (:company_a = 1 OR v.company_id = :company_b)
```

plus the Mine predicate when `scope = 'mine'` (`p.project_manager = :user`).

Filtered to projects that have at least one of the two dates — which is what "only projects
with dates" means, subject to the Phase 0 answer on one-date projects. The count of
projects excluded for having no dates is returned alongside, so the page can say how many
are not shown and link to the list rather than quietly losing them.

Each row returns: `project_id`, `project_name`, `parent_project_id`, `top_project_id`,
`top_project_name`, `project_status_id`, `project_status_desc`, the **normalised** style key
(`statusStyleNormalise()`, so a legacy token resolves and an unknown one becomes null rather
than throwing), `project_manager_name`, `account_name`, `start_date`, `ecd_date`,
`completion_date`, and `valid_premises` for the row detail.

**The flags.** `dashboardFlags($dbh, $userID, $companyID, $scope, ['projects'])`, grouped
by `entity_id` into `flags[project_id] = [ {flag, badge, sev, icon, label, metric,
priority}, … ]`. Same scope value as the rows, for the reason in Decision 5.

**The envelope** also carries `styles` (`statusStyleRegistry()`, so the client resolves
tokens to `css`/`ink` the way `get_update_form.php` and `dashboard_load.php` already do)
and `date_labels` — the `field_name` of the configured start and ECD fields, so the axis
legend and tooltips say "Planned Start" and "Estimated Completion" rather than "start" and
"ecd". The field names are client wording and the UI should use them.

**Cost.** Two date subqueries per project row (each a primary-key lookup on a
`UNIQUE (field_id, project_id)` index) plus up to four flag queries. The flag queries are
the same ones the dashboard already runs on every load, for one module instead of five. If
the row query proves slow at volume, the date expressions become `LATERAL` joins before
anything is cached — `vw_projects_list` is already behind a `LEFT JOIN` whose `prems` CTE
is computed in full, and that is the more likely cost.

### Phase 4 — Route, nav and page shell  ✅

**`fn/routes.php`** — a `projectplan` entry, module `projects`, permission `projects`,
layout `'2'` (the chart wants full width, like the stocklist and dashboard pages):

```php
'projectplan' => [
    'module' => 'projects',
    'permission' => 'projects',
    'layout' => '2',
    'head_nav' => ['html/html_header_nav_v2.php'],
    'nav_html' => [''],
    'html' => ['html/html_body_project_plan.php'],
    'css' => ['css/project_plan.css', 'css/dashboard.css'],
    'js' => ['js/project_plan.js'],
],
```

`dashboard.css` is loaded for the `.dash-chip` flag badges — literally the rules the
dashboard uses, the same reasoning the `admin_fields` and `admin_dashboard` routes give for
loading it.

**`html/nav_global.php`** — a third child under Projects, below Project List:

```php
['label' => 'Project Plan', 'icon' => 'fas fa-stream', 'route' => 'projectplan'],
```

`fa-stream` exists in the vendored Font Awesome 5.15.4 (`www/css/all.css`) — the local copy
is what is served, so an FA6-only icon such as `fa-chart-gantt` or `fa-timeline` would
render as an empty box.

**`$navRouteToModule`** — add `'projectplan' => 'projects'` so the Projects tab lights up.

**`html/html_body_project_plan.php`** — the project-list shell: a `.project-header-bar` with
the title and a short stats line, `include __DIR__ . '/nav_global.php'`, then `<main>` with
the toolbar, the overview strip, the chart container and the delivery footer.

The toolbar carries, per Decisions 14 and 18: the **All/Mine** toggle, a **status**
multi-select, an **account** picker, the **zoom** control (week default), the **density**
control (compact default), and a **Today** button. No configurable field filters — that is
v2, and Decision 18 says why.

No `#uploadModal` is needed — this route does not load
`project_edit_v2_fileuploads.js`.

### Phase 5 — The chart  ✅

`www/js/project_plan.js` and `www/css/project_plan.css`, hand-rolled. No new library: there
is no build system, CDN vendoring is parked, and the bars have to carry status tokens, flag
chips, two kinds of slip, a baseline and collapsible group rows that a general-purpose Gantt
would fight. The prototype has proved the layout; it is not the implementation and no file
from it ships as is.

Structure, per Decisions 6–17:

- **One scroller, two directions.** The label column is sticky-left and the header
  sticky-top inside the *same* `overflow:auto` box. The prototype found this matters: there
  is then no scroll-sync code and the panes cannot come apart, because they are not separate
  panes. The delivery footer is sticky-bottom in the same box for the same reason.
- **A two-tier sticky header** — coarse over fine, the coarse label pinned left within its
  own cell. `overflow: visible` on that cell is load-bearing: `overflow: hidden` makes the
  cell itself the scrollport and the sticky label then does nothing at all.
- **An overview strip** above the chart — a percentage-positioned histogram of projects
  across the whole extent, with a draggable viewport box. Percentages, so it needs no
  measurement and survives a resize without a re-render.
- **A today line** through the full height, and a Today button. Initial scroll is today, not
  the earliest project.
- **Rows** — group headers (collapsible, counted) and project rows, sorted by start date
  ascending. A group whose count does not match the rows it opens to reads `8 of 12`, which
  is the one-date exclusion showing through rather than being discovered by counting.
- **Bar fill** from the status style token via `styles[key].css`, text in `styles[key].ink`.
  An unstyled status falls back to neutral — `statusStyleResolve()` is forgiving by design
  and the client must be too. A missing key must not throw.
- **Slip** — realised and running, per Decision 7.
- **Baseline** — the original ECD as a hairline, per Decision 9.
- **Flag chips** in the label column using `.dash-chip` with the flag's `sev`, highest
  priority first, collapsing to `+N` at compact per Decision 16.
- **A delivery footer** — premises per period, per Decision 12.
- **Click a bar or a label** → `index.php?do=projectedit&project=<id>`. A group header's
  label collapses instead: clicking the row you are reading must not navigate away from it.
- **Hover** → a tooltip with the full name, status, PM, account, the dates, lateness in
  days, where the ECD was before it moved, and the flag metrics.
- **Empty and unconfigured states** — distinct, and both explain themselves. "No project has
  a plan date yet" is not the same as "no start date has been configured for projects", and
  the second one names where to go.
- **Bad data is drawn as it is.** A start after its ECD draws hatched between the two dates
  with a warning glyph, never reversed into something tidy; a completion before its start
  draws where it is. An impossible bar is a data problem worth seeing.

A layering order the prototype had to fix twice, worth writing down once: grid 0 · today
line 1 · bars auto · summary bar 2 · overhang and completion 3 · label column 4 · header 6 ·
header label 8 · delivery footer 7/9. Anything drawn inside a track must stay below 4, or it
paints over the sticky label column.

Vertical scrolling is the container's own. The prototype's 250-project scenario rendered
without stalling, so **virtualisation is not built**; revisit only if real data says
otherwise.

### Phase 6 — Documentation  ✅

- This plan updated — status, phases complete, and the Phase 0 answers written into
  **Decisions** so the reasoning survives.
- `CLAUDE.md` — `projectplan` added to the route table; a short note under the Field Meta
  section that `dashboard_config` now carries a fourth role, and that the flag helpers live
  in `global_functions.php` rather than `dashboard_load.php`.
- `docs/features.md` — the plan view under the projects module.
- `docs/database-migrations.md` if it carries a migration list.
- Anything the build turns up that is not this feature goes in
  `docs/improvement-opportunities.md`, not into this doc.

## Testing checklist

Restated in chat at the end of each phase, and stopped on for confirmation.

1. Migration 111 applies cleanly, and both constraint definitions contain `start` when read back from `pg_constraint`. (Not "re-running it is refused" — `migrate.php` tracks applied files in `public.schema_migrations` and never runs one twice, so an in-file guard only ever fires on a hand-applied `psql -f`.)
2. Module Management → Projects → Dashboard shows a Start date dropdown listing the module's active date fields.
3. Setting a start date, saving, and reloading the page shows the saved value.
4. Setting the start and the ECD to the same field is refused with a message naming both.
5. Clearing the start date saves, and the dashboard behaves exactly as before.
6. `?do=admin` shows Projects as configured when only a start date is set.
7. The dashboard queue, module cards and trend charts are unchanged after the Phase 2 move — same rows, same flags, same counts.
8. The other four modules also show a Start date control, and saving one does not disturb another.
9. Projects → Project Plan appears in the nav dropdown below Project List, and the Projects tab is highlighted on it.
10. A user without `projects` read permission cannot reach `?do=projectplan`.
11. A non-company-1 user sees only their own company's projects, and the same set the project list shows them.
12. A deleted project does not appear on the plan.
13. A project with both dates draws a bar spanning them; the bar's colour matches its status badge in the editor.
14. A project with no dates does not appear, and the count of excluded projects is stated on the page.
15. An overdue project carries the Overdue chip with the same metric the dashboard shows for it.
16. Risk, Check and Stale chips appear on the same projects the dashboard flags.
17. Switching All/Mine changes both the projects shown and the flags shown, consistently.
18. A lineage group collapses and expands, and the collapsed summary bar reads correctly against its members.
19. The chart scrolls backwards and forwards in time, and the today line stays in the right place.
20. Loading the page lands on today, not on the earliest project.
21. Clicking a bar opens that project's editor.
22. Zooming between week / month / quarter keeps the same projects on screen.
23. With no start date configured, the page explains what is missing and where to set it.
24. With a start date configured but no project carrying a value, the page says so rather than rendering an empty grid.
25. A hundred-plus projects scroll without stalling.
26. A project with only one date does not appear, and the count of one-date projects is stated on the page separately from the no-date count.
27. A project that finished late draws a slip from its ECD to its actual completion, closed at that date, and the tooltip states how many days late.
28. A project that is late and unfinished draws a slip from its ECD to today, ending in an arrow, and the tooltip says "and counting".
29. A project that finished early draws no slip, and its completion diamond sits inside its own bar.
30. A project whose ECD has moved shows the original ECD as a hairline back to the date first promised.
31. A project whose ECD has never moved shows no baseline mark at all.
32. The overview strip shows where work sits across the whole extent, and dragging its viewport box scrolls the chart.
33. The overview strip reveals work several years out without scrolling to it.
34. The delivery footer stays aligned to the axis while the chart scrolls horizontally, and stays at the bottom while it scrolls vertically.
35. The delivery footer's total reconciles with the sum of `valid_premises` across dated projects, and states how many premises are undated.
36. A parent's label shows its own premises count and its lineage total, and the two differ where it has children.
37. Rows are ordered by start date ascending within a group, and groups by their earliest start.
38. At compact, a row with three flags shows the highest-priority badge plus `+N`, with the rest on hover.
39. At compact the name is still readable on a row carrying flags and a premises count.
40. Status filter and account filter narrow the chart, and combine with All/Mine.
41. A project with a start after its ECD draws hatched between the two dates with a warning, and is not tidied.
42. A project with an unstyled status draws in neutral and does not break the page.

## Risks

- **The Phase 2 move is the one that can break something that works.** Fourteen functions
  leave `dashboard_load.php` (this said eleven; the list in Phase 2 was right and the count
  was not). A missed call site is a fatal in the dashboard, not in the new page. It is
  phased separately and tested on its own before anything is built on it.
- **`dashboardFlags()` cost.** Up to four queries, one of them a window function over the
  whole project history table. The dashboard already pays this for five modules on every
  load, so one module is cheaper than today — but if the plan view is left open and
  refreshed it will pay it each time. Fire the flag request after the rows render, the way
  `wayleave_badge_counts.php` separates its spatial count from editor load.
- **No start date configured anywhere yet.** Until an admin sets one, the page has nothing
  to draw. That is correct behaviour and it is why the unconfigured state is a first-class
  screen rather than an afterthought — but it does mean the feature ships inert and needs a
  configuration step before anyone sees it work.
- **Hierarchy depth.** Grouping is on `top_project_id`, so a three-deep lineage flattens to
  two levels: the lineage root and everything under it. Whether that is enough, or whether the
  rows need to nest to their real depth, is a Phase 0 question — nesting arbitrarily deep is
  materially more work and may not earn it.
- **Dates that disagree.** A child ending after its parent, a start after an ECD. The chart
  must draw these as they are and not tidy them — an impossible bar is a data problem worth
  seeing, and hiding it behind a clamp would make the chart lie.
