# Dashboard Redesign

- **Date:** 2026-07-28
- **Status:** Complete — tested and signed off *(2026-07-31)*; one post-signoff fix
  *(2026-08-04, below)*
- **Status Date:** 2026-08-04
- **Phases:** 5 (was 6 — cross-module insights and risk flags parked 2026-07-30)
- **Phases Complete:** 5 of 5. Migrations 019 and 020 applied. Tested in the browser: the
  admin config screen loads, saves and persists; enabling and disabling flags changes the
  dashboard as intended; the page reads correctly at full width and on a phone.
  **Two things stay open by decision, not omission:** list lengths and the module-grid
  strip question, both easier to judge with real data now on screen. **Wayleave and
  opportunities are out of v1** — see *Turning these modules on later* for what each needs.
- **Notes:** **Design 5 (Hybrid B) chosen 2026-07-30**, with Stages on the module cards, the
  two-line item row, in-place expansion, and the queue collapsed on load / absent at zero
  items. Five candidates were prototyped in `www/test.php`; designs 1–4 stay switchable there
  for reference until the build is signed off. **Scope: projects, accounts and stocklists in
  full; opportunities as a count-and-recents card; wayleave cut from v1** because its status
  table diverges and including it would mean a workaround for an unfinished module — see
  *Modules in scope*. **Phase 2 (added 2026-07-30) makes the ECD and
  completion dates client-configurable per module**, via a dashboard config screen — no
  hard-coded field ids in endpoints or database views, because a date the client can't change
  will be wrong for the next client. Where a date isn't set the dashboard degrades to Recents.
  Phase 1 still carries the opportunities schema question (no owner, no status), unassumed.
  List lengths are deliberately deferred to Phase 3. **Two things are parked to get v1
  delivered** — cross-module insights and cross-module risk flags — so **nothing in the build
  depends on unbuilt work in another module**; both are written up under Parked with their
  un-park conditions. The prototype has been
  syntax-checked and render-checked across all 280 design × scenario × card-line × queue
  combinations, but **has not been viewed in a browser** — no local web server was running and
  no browser tooling was available in the sessions that wrote it.

## Plan Phases

0. ✅ Design selection — prototype review *(2026-07-30, design 5)*
1. Data foundation — endpoint restructure and the opportunities schema decision
2. Dashboard date configuration — which date field means what, per module
3. Page build — the chosen layout
4. Chart tokens and encoding

**Parked** *(2026-07-30)*, both under their own sections below: cross-module **insights**, and
cross-module **risk flags**. Nothing in Phases 1–4 depends on either.

## Problem

`?do=dashboard` is the app's landing page and does three things, none of them well.

**It doesn't know who you are.** Both charts (`fn/dashboard_load.php::premisesChartData` /
`completedChartData`) aggregate every project in the database with no user, company or
permission filter. A contractor with read access to six projects sees the same organisation-
wide totals as a director. The only personalised thing on the page is the recently-opened
list. Nothing tells you what *you* need to do.

**It shows volume, not state.** Two grouped bar charts of premises created and premises
completed per month. Neither answers a question a planner has on a Monday morning: which
wayleave is blocking a build, which project's ECD moved again, what is sitting in survey.
The data for all of those exists — `reports.vw_project_ecd_change` computes ECD movement
with a change type and a change count, `wayleave.agreements` carries `ecd_date` and
`signed_date`, `vw_projects_list` carries premises and MDU counts per project — and none of it
reaches the page.

**The recently-opened list is right but oversized.** It is the piece worth keeping — the
access-log union in `recentItemsList()` covers all five entity types and it is genuinely
useful. But it renders as a `bootstrap-table` with a search box and a 25/50/100 page-size
selector over a query that is `limit 20`: pagination that can never paginate, and a search
box for a list you can read at a glance. It occupies most of the page below the charts.

### Secondary problems, same page

- **The second chart series is customer-specific.** "With BDUK Vouchers" is one customer's
  metric, and `vw_projects_list` produces it by joining `adhoc.bduk_uprn_tracking_v2` — a
  deployment without that table cannot compute it at all, which is what the `adhoc` schema name
  was signalling. **Descoped from v1 (2026-07-30):** the new trend chart plots total premises
  as a single series. If BDUK returns it must *stack*, not sit alongside — it is a subset of
  total premises, so side-by-side bars imply comparable quantities and leave the total
  unreadable.
- **Every bar carries a datalabel** in both series across twelve months — 48 numbers on two
  charts, which is why the page reads as busy.
- **`field_id = 20` is hard-coded** in `completedChartData()` and `completedChartDrilldown()`
  to mean "completion date". Field ids are per-deployment, so the Completed chart is silently
  wrong (or empty) on any database where that id means something else.

## Modules in scope

**Projects · Accounts · Stocklists** get the full treatment: config, flags, Stages, cards.
**Opportunities** gets a card with a count and recents only. **Wayleave is cut from v1.**

**Why wayleave is cut** *(2026-07-30)*. Its status table diverges from the other three:

| | Key | Label | Extras |
|---|---|---|---|
| projects / accounts / stocklists | `<module>_status_id` | `<module>_status_desc` | — |
| **wayleave** | `id` | `description` | `display_order`, `is_active` |

Every module naturally has its own table and column *prefix*, and a map of those is just data.
Wayleave is different in *shape*, so including it means a hand-written exception in that map —
a workaround for an unfinished module, carried in the dashboard. It gets cut instead, and
returns for free once the module is aligned: the backlog's **"Align wayleave to the other
modules"** item covers exactly this, and the rename is small (two columns, six call sites —
`get_update_form.php:48`, `wayleave_load.php:50`, `wayleave_save.php:34`,
`wayleave_audit_log_load.php:50,63`). It is not done here because it is wayleave's work, not
the dashboard's.

**Why opportunities is not cut.** It needs no exception. It has no field-meta tables, so it
simply has no entry in the config map and never appears on the config screen; with no status
configured it shows no Stages line and no flags, and its card body falls through the chain to
Recents. That is the degradation the design already specifies, driven by config rather than by
a module check. Its count and recents come from `prospector.opportunity` and
`opportunity_access_log`, which are the same shape as every other module's.

**The cut is to status-dependent features, not to the module.** No wayleave card, no wayleave
flags, no wayleave row on the config screen — but **wayleave records still appear in Recently
opened**, which needs only the access log and a name, both of which are uniform. The module
stays live in the nav; a user can still open and work on agreements.

**Consequence to accept:** the ECD flags were originally motivated by unsigned wayleaves past
their ECD. With wayleave out, flags 1–3 run on projects primarily — still the core case, but
the wayleave example that sold the idea is not in v1.

### Turning these modules on later

Written 2026-07-31 against the built code, so it is a checklist rather than a sketch. Most of
it is one entry in `dashboardModuleMap()` (`www/fn/global_functions.php`) — everything that
iterates modules reads that map, so the config screen, the cards, the flags and the queue all
pick a new module up at once. The rest is the four small lookups beside it and one icon.

#### Wayleave

**Blocker — the status table.** Align `wayleave.agreement_status` to the shape the other three
share: `id` → `agreement_status_id`, `description` → `agreement_status_desc`. Six call sites
follow it: `get_update_form.php:48`, `wayleave_load.php:50`, `wayleave_save.php:34`,
`wayleave_audit_log_load.php:50` and `:63`. This belongs to the backlog's *"Align wayleave to
the other modules"* item, not to the dashboard.

Then, in the dashboard:

1. **`dashboardModuleMap()`** — add `wayleave`: entity `wayleave.agreements`, entity_id
   `agreement_id`, fields `wayleave.agreement_fields`, values_date
   `wayleave.agreement_field_values_date`, the aligned status table/columns, entity_status
   `agreement_status_id`.
2. **`dashboardNameColumn()`** → `agreement_name`; **`dashboardHref()`** →
   `?do=wayleaveedit&agreement_id=`; **`dashboardModuleListHref()`** → `?do=wayleave`;
   **`dashboardMineSql()`** manager map → `bd_manager`;
   **`dashboardEntityHistoryTable()`** → `wayleave.agreements_history`.
3. **`MODULE_ICON`** in `js/dashboard.js` → `file-earmark-text`.

**Three divergences that will bite, all verified 2026-07-31:**

- **`wayleave.agreements` has `is_deleted`; the other three have no such column.** Every
  dashboard query would count and flag deleted agreements. The map needs a per-module "extra
  predicate" hook, or the column needs to go as part of the alignment work — the latter is
  cleaner, since a soft-delete on one entity out of four is its own inconsistency.
- **The EAV history column is `field_value`, not `value`** (and `history_datetime`, not
  `record_datetime`). Flag 2 reads those names directly, so it would fail on wayleave until
  either the columns or the builder accounts for it.
- **Migrations 017 and 018 do not touch wayleave** — its history is still written by
  `wayleave_save.php` rather than by triggers. So history is only as complete as that one code
  path, and `history_action` is whatever the PHP writes rather than `TG_OP`. Confirm those
  values are `INSERT`/`UPDATE` before trusting flag 2's move count, and note that any other
  write path leaves no history at all, which quietly weakens flag 4.

#### Opportunities

**Blocker — it has no field-meta tables and no status.** `prospector.opportunity` has only id,
name, created/modified user and datetime, `geom` and `company_id`. Enabling it needs the forms
work: the `opportunity_fields` meta set and value tables, a status table plus
`opportunity_status_id`, and `opportunity_manager` if "Mine" is to mean anything more than
"I created it".

Once those exist, the dashboard side is the same three steps as wayleave, with icon
`binoculars`. No divergences to work around — it would be a clean addition.

**Note the current state honestly:** opportunities has **no card today**. This plan says it
should show a count-and-recents card, but `dashboardModules()` builds cards from
`dashboardModuleMap()`, which opportunities is not in — so it appears only in *Recently
opened*, via the access-log union in the `recent` mode. That is defensible (a card with no
status, no stages and no flags is a number and a list) but it is not what this document said,
and it should be a decision rather than an accident. **Either add a map entry that declares no
status/date support, or amend the intent to "recents only".**

## Patterns to follow

**Copy the projects module and the newer admin pages — not wayleave, and not the dashboard's
own current code.** Both of those are the divergences this plan should avoid propagating:
wayleave is mid-realignment (`docs/improvement-opportunities.md`), and `js/dashboard.js` is
old-style globals. Nothing below is new convention; it is what the codebase already does.

**AJAX endpoint** — pattern source `fn/project_load.php`, `fn/admin_load.php`:

- `header('Content-Type: application/json')`, then `ob_start("ob_gzhandler")`, requires for
  `global_vars.php` / `global_functions.php` / `db.php`.
- `include 'login_check.php'; $check = loginCheck('func');` → on failure echo
  `['success' => false, 'message' => 'Not Logged In']` and exit. Otherwise
  `$userID = $_SESSION['id']`.
- `isModuleEnabled($pdo, '<module>')` where a module gates the endpoint.
- Permission via `getModulePermission()` / `getItemPermission()` / `permissionSatisfies()`, and
  company via `userCheckCompany()` — company 1 sees all.
- **A `$allowedModes` whitelist**, checked before the switch, one mode per widget. `admin_load.php`
  goes further with a **mode → permission map** (`'map_layers_list' => 'admin_fields'`) — do that
  for the dashboard config endpoints.
- Response shape `['success' => bool, 'message' => …, plus data keys]`.
- **Two deliberate departures from the pattern**, because the pattern is wrong here: reuse
  `$pdo` rather than constructing a second `new PDO` (so the history-trigger `app.user_id`
  attribution applies), and do **not** copy the `ini_set('display_errors', '1')` line — it
  corrupts JSON responses and is already logged as a backlog item.

**Admin page JS** — pattern source `js/admin_map_layers.js`:

- Wrapped in `(function () { 'use strict'; … }())`, no globals.
- Section banner comments (`// ── Tabulator ───…`).
- `$.post('fn/admin_load.php', { mode: … }, fn, 'json')` for reads; `$.ajax` for writes.
- Tabulator for tables, Bootstrap modals for edit dialogs, `bootstrap.Modal.getOrCreateInstance`.
- Wire up in a `DOMContentLoaded` handler at the bottom.
- HTML-escape helper for anything interpolated into markup.

**Admin page markup** — pattern source `html/html_body_admin_map_layers.php`: the
`.project-header-bar` block with `<h1>` and a `.project-stats` subtitle ("GeoLynx
Administration"), then `</main>`, then `include nav_admin.php`, then
`<main class="p-3" id="main">` with the body. Styles come from `css/admin.css`.

**Dashboard page JS** — the page is being rebuilt, so it moves to the admin-page style above
(IIFE, `'use strict'`, no globals) rather than continuing the current file's bare global
functions. Keep one render function per widget so widgets stay independently buildable.

**Migrations** — numbered `db/NNN_*.sql`, header comment naming the plan doc, idempotent
(`IF NOT EXISTS`) where sensible, applied via `php db/migrate.php`. Immutable once applied.

## Build

### Phase 0 — Design selection ✅ *(2026-07-30)*

> **Outcome: design 5 (Hybrid B).** With Stages on the module cards, the two-line item row,
> overflow that expands in place, and the queue **collapsed on load — and absent entirely when
> there are zero items**. Everything below is retained as the record of how that was reached;
> designs 1–4 remain switchable in `www/test.php` for reference until the build is signed off.
>
> Still open, and owned by later phases: **list lengths** (Phase 3, deferred by decision) and
> **whether design 5's module grid collapses to a strip below the fold** (Phase 3). Everything
> else was settled across 2026-07-30 — the design, the item row, the flag set and badge
> vocabulary, the module config, and opportunities.

`www/test.php` holds the candidate designs against four data scenarios. Review and pick;
everything after this is written against the winner.

| | **1 · Focus** | **2 · Portfolio** | **3 · Modules** |
|---|---|---|---|
| Organising idea | personal work queue | one headline number, then its shape | a card per module you can see |
| Lead element | exception feed ("needs attention") | hero figure + stage bar | grid of module cards |
| Audience | daily users — PMs, wayleave officers | weekly review — leads, directors | mixed teams, multi-client |
| Recently opened | 8-row rail | 5-row rail | one card among equals |
| Charts | demoted below the queue | central, with a written conclusion | one wide card |
| Build cost | highest (each rule is a query + a definition) | medium (filters must reach every query) | medium but wide (N queries, N empty states) |
| Main risk | rules too loose → ignored; too tight → empty | impersonal; hero number needs an agreed definition | no focal point; sparse when restricted |

**Design 4 — Hybrid**, added after the first review. The likes came from all three: design 1's
needs-attention queue, the compact recently-opened list, design 2's insights panel, design 3's
module cards. Design 4 is those four in one page, reading **today → why → where**. One
deliberate subtraction: **no KPI tile strip** — the module cards already carry a headline
number each, so tiles would state the same figures twice. Its own risk is length; on a laptop
the module grid sits entirely below the fold, which is either correct (the queue is the point)
or an argument for collapsing the grid to a strip. Not yet decided.

**Design 5 — Hybrid B · CHOSEN.** Added at the same review. Same pieces, opposite arrangement: issues
move **into** the module cards. Each card shows **up to three rows**: issues first, then topped
up with that module's recently opened. One issue gives you that issue plus two recents — the
slot is never wasted.

*(Corrected 2026-07-31: an earlier draft had a third rung filling any remainder with the
module's largest records. That was never asked for — it crept in when the chain was first
built and was then written up here as though agreed. It is also largely redundant: creating a
record means opening it, so the newest records are already in recents. Dropped, saving a query
per module. A card with nothing to list keeps its count and stage strip, so it does not read
as broken.)*

#### The item row — **agreed at review**

One component, used by the module cards and the queue panel alike. Two lines, two columns.
This layout is settled, and the flags that populate it were defined on 2026-07-30.

```
Grove Park MDU — Block C                    [ Overdue ]
Wayleave · ECD overdue, unsigned              18 days
```

- **The name starts hard left on every row**, so names align down the column. A leading badge
  cannot do that — "Overdue" and "Check" are different widths, so each name began at a
  different x. Moving the badge right was the fix.
- **Names wrap to two lines before truncating** (`-webkit-line-clamp: 2`), with the full text
  on `title`. A fibre project name is routinely 60+ characters; one clipped line tells you
  nothing. The descriptor line wraps to two as well.
- **Small badge vocabulary:** Overdue · Risk · Check · Recent — one per flag type, plus Recent.
  Four badges for four states, and nothing else earns one.
- **The right-hand number is the thing you would have asked next** — days overdue, days of
  slip, days stale, premises found, time since opened.
- Each exception therefore needs **two forms**: a long `reason` for the queue panel and a
  compact `label` + `metric` pair for the row. Cheap, but it is a schema decision for the
  endpoint, not a formatting detail — build the endpoint to return both.

**Settled 2026-07-30 by the flag definitions:** the vocabulary is *Overdue · Risk · Check ·
Recent*. "Slipping" became **Risk**; **Tidy** went unused — nothing in the defined set is
housekeeping — so it folds away.

**Dedupe on entity id, not name.** A record that is both flagged and recently opened is the
*likely* case — the thing with a problem is the thing you just opened — and it must not appear
twice on one card. The flagged row wins; it carries strictly more information. The prototype
matches on title because its mock rows have no ids, and that was enough to let a near-duplicate
through during testing. Two stocklists can share a name, so the build matches on id.

The gain is **context**: the overdue wayleave sits directly under "23 agreements · 16 signed",
so the fault and the state of the module read together. In design 4 the queue row names a
module you then have to go and look up.

What it gives up is **cross-module severity**. A 96-day overdue wayleave and an accounts
tidy-up get equal billing because they sit on different cards, so on the Heavy scenario you
must scan six cards to find the worst thing on the page — the exact job the queue existed to
do. The queue at the top is what buys that back.

#### Expand in place, don't link out — **agreed at review**

An earlier draft had the summary bar carry an "Open the full list" button, i.e. a link
elsewhere. Wrong instinct: it discards the page the reader is on to show rows that could have
been just below the fold. Everything now expands where it stands, following the language the
app already has:

- **The queue is the shipped `.hx-panel`** — the same component, classes and Show/Hide
  affordance as the project hierarchy panel (`custom.css:900`, handler at `main.js:700`). The
  prototype uses the real classes, not copies, since `custom.css` is loaded; the only additions
  are a severity tint on the head and making the item rows sit flush in the body.
- **Collapsed, the panel head *is* the summary line** — count, module count, severity split,
  Show button. So "summary bar" and "full panel" stopped being two competing designs and became
  two states of one component.
- **Agreed: collapsed on load, and no panel at all when there is nothing to flag.** Not a
  "nothing needs attention" panel — an empty state that reappears every day is how a region of
  the page teaches people to stop looking at it. The New user scenario shows the result: the
  queue is simply absent, and the page starts at the module grid. Implementation note: the
  wrapping `<div>` has to go with the panel, or its margin leaves a gap where the panel wasn't.
- **Long lists reveal their tail in place**, mirroring `[data-hx-more]` (`main.js:707`): rows
  past the sixth are rendered but hidden, and the control removes itself once used. No request,
  no navigation, no lost scroll position. The module cards do the same with their own overflow.

**Row counts are a build-time decision, not a Phase 0 one** (agreed at review). The prototype
uses three rows per card and six before the queue's overflow control, but the right numbers
depend on how the real page feels with real data. Related: on Heavy the Projects card holds ten
issues, so expanding it in place makes one grid cell ten rows tall while its neighbours stay
short — whether a card caps its own expansion and hands the remainder to the panel is part of
the same call. Settle all of it in Phase 2 against a real dataset.

One consequence to watch: **a card with three issues shows no recents at all**, so on a bad
week the module cards stop being a route back to what you were working on. The standalone
Recently opened card is what covers that — an argument for keeping it in the grid rather than
treating the cards' recents as a replacement.

The scenarios are what separate them: **Typical**, **New user** (owns nothing, has opened
nothing), **Heavy** (long names, seven-figure numbers, 37 exceptions), **Restricted**
(wayleave / opportunities / strategy disabled, read-only, owns nothing). All three look fine
populated. Design 1 is emptiest for a new user; design 3 is sparsest for a restricted one.

The **Today's page** button in the prototype bar renders the current dashboard from the same
mock data, so the comparison is like-for-like rather than from memory.

**Decisions needed from this phase, beyond picking a design:**

1. Does the page default to **Mine** or **Everything**? (Designs 1, 4 and 5 default to Mine;
   design 2 defaults to Everything.)

   **Settled at review: the switch is two options, not three — "My company" is dropped.**
   `data_get.php:219` already applies `and (:company = 1 or company_id = :company)` to every
   list, so for any user outside the main company "my company" and "everything" resolve to the
   same query. For the main company — who are looking after subcontractors — "everything" is
   the one they want. A third button would have been a no-op for most users and a trap for the
   rest. Consequence to keep in mind: **"Everything" means everything *you can see***, which
   is already what the list pages mean by it.
2. What does "live" mean in a module card's headline count ("41 live projects")?

   **Settled 2026-07-30 by the Phase 2 config: live = status not in the inactive set.** Same
   definition the flags use, so a card's count and its flags can never disagree. Where no
   inactive set is configured the count is simply every record, and the card should say
   "41 projects" rather than "41 live projects" — don't claim a qualifier that isn't being
   applied.
3. Should the dashboard hide widgets for modules the user cannot see?

   **Settled by choosing design 5**, which renders its grid from the module/permission list, so
   a module the user cannot see produces no card and no query.
4. **What the module card's second line shows** — five candidates, switchable in the
   prototype's **Card line** control. See below; this is a decision in its own right.

#### The module card's second line

The original design-3 card carried a sparkline of the record count over 12 months. That was
wrong and was called out at review: a record count only ever accumulates, so the line always
rises — it looks like a trend and says nothing beyond "more records exist now". The rule the
replacements are judged against: **a card shows a state or a rate, never a cumulative total.**
Wayleave was the one card that read well in the original, because it alone used a meter.

| Option | Shows | Trade-off |
|---|---|---|
| Count line | 12-month record count | The rejected original. Kept only for comparison. |
| Movement | "+3 added · 1 completed this month" | Honest and varies, but six cards of grey text have no shape to scan. |
| Pace vs ECD | "1 completed · 3 were due this month" + Behind / On plan | Progress against a target without capturing targets. |
| Meter | "16 of 23 signed · 70%" | Reads at a glance, but a lifetime ratio barely moves week to week. |
| **Stages** | mini stage strip by status | **Chosen.** Most information per pixel; the key wraps on a narrow card. |

**Decided at review: Stages.** It reads as a shape rather than a sentence, which is what makes
a grid of cards scannable, and it reuses design 2's stage strip so there is one stage component
on the page, not two.

Two consequences to carry into the build:

- **It needs a status per module.** Opportunities have none (see Phase 1), so that card falls
  back to the movement line and flags why. This is now the strongest argument for giving
  opportunities a status — it is the only module whose card is visibly degraded without one.
- **The micro-key under the strip is what makes the colours legible**, so it has to survive
  the narrow case: five stages wrap to two lines on a 21rem card. Test it before assuming the
  strip works without the key.

*Not chosen, but worth keeping in mind for a later pass:* **Pace vs ECD** is the only option
that shows progress against a target without capturing targets, since the ECD your users
already entered *is* the target. It also carried a dependency Stages does not — it would have
made the hard-coded ECD field ids load-bearing rather than incidental.

### Phase 1 — Data foundation

Restructure `fn/dashboard_load.php` following the endpoint pattern above. It currently mixes a
whitelist of five modes with eight unused request parameters and a dead
`isAlphanumericWithSpaces()` helper, opens its own PDO handle rather than reusing `db.php`'s
`$pdo`, and turns on `display_errors` in a JSON endpoint (any PHP warning corrupts the
response). Fix all of that as part of the rewrite: one mode per widget, `$pdo` reused so the
history-trigger `app.user_id` attribution applies, errors logged not printed.

Every mode gains three things the current ones lack:

- **User scope** — `:user_id` against `project_manager` / `account_manager` /
  `stocklist_manager` / `bd_manager` (all four exist), or "everything", per the Phase 0
  decision. Opportunities are the exception — see below.
- **Company scope** — the existing `company_id` rules (`docs` memory: main company sees all;
  `NULL company_id` is main-company-only).
- **Module + permission gating** — reuse `getModulePermission()` and the `public.app_modules`
  lookup that `html/nav_global.php` already applies, so a widget for a disabled module is
  never queried, not merely hidden.

**Two schema decisions block parts of this. Neither is assumed here.**

1. **Opportunities: recents only. Settled 2026-07-30** — but on a wider gap than expected, so
   the reasoning is worth recording.

   **Verified against the schema (2026-07-30):** `prospector.opportunity` holds only
   `opportunity_id`, `opportunity_name`, `created`/`modified` user and datetime, `geom`, and
   `company_id` (added by `db/007`). There is **no status column, no owner column, and no
   `opportunity_status` table** — and, unlike the other four modules, **no field-meta tables
   at all**: no `opportunity_fields`, no sections/categories, no `*_field_values_*`. It is the
   one module with no dynamic-field system, which matches the intent to add forms to it later.

   Two consequences follow, and the second is the one that matters:

   - The card can show no status, so **no Stages line and no flags** — its body falls through
     the chain to **Recents**, which is the agreed behaviour and needs no special-casing.
   - **Opportunities cannot take part in Phase 2 config at all.** With no field-meta rows there
     are no date fields to offer and no statuses to pick, so all four settings have nothing to
     populate their dropdowns. The config screen should show the module with its controls
     disabled and say why — pointing at the missing forms rather than rendering four empty
     selects that look broken.

   No schema work in this plan. When opportunities gets its forms and a status, it joins the
   config screen and the flags with no dashboard changes — which is the point of everything in
   Phase 2 being configured rather than coded.
2. **~~ECD and completion dates are hard-coded field ids.~~ Superseded — this is now Phase 2.**
   Resolving by name would only have moved the guess from an id to a string. The dates are
   client-defined, so the client picks them; see Phase 2.

Cheap wins in the same phase, independent of the chosen design:

- Cap the recent-items query at what the UI shows (8, not 20) and drop the bootstrap-table
  wrapper entirely — the compact list in the prototype is plain markup.
- Return `item_type` as a code plus a label so the list can render a type badge without
  string-matching in JS.

### Phase 2 — Dashboard date configuration

"Completed this month", "ECD due", "ECD overdue" all beg the same question: **which date?**
The app must not answer it. A build team may keep a *Build Complete* date and a *Release to
Sales* date and reasonably want either one driving the dashboard — that is their vocabulary,
not ours. So the client chooses, per module, from the date fields they already have; if a
suitable one doesn't exist they create it with the field admin that already exists.

**The corollary is the point of the phase: no hard-coded dates anywhere.** Not
`field_id = 20` in `dashboard_load.php`, and not `field_id = 8` baked into
`reports.vw_project_ecd_change`. A date the client can't change is a date that will be wrong
for the next client.

#### Mechanism

**Four settings per module**, in two tables — three single-valued, one a set:

```
public.dashboard_config                      -- the three scalars
  module      varchar   -- 'projects' | 'accounts' | 'stocklists'  (see Modules in scope)
  role        varchar   -- 'ecd' | 'completion' | 'complete_status'
  field_id    bigint    -- a row in that module's *_fields meta table  (date roles)
  status_id   integer   -- a row in that module's *_status table       (status role)
  unique (module, role)
  check (  (role IN ('ecd','completion') AND field_id IS NOT NULL AND status_id IS NULL)
        OR (role = 'complete_status'     AND status_id IS NOT NULL AND field_id IS NULL) )

public.dashboard_inactive_statuses           -- the set: "work is no longer happening"
  module      varchar
  status_id   integer
  unique (module, status_id)
```

A junction table rather than a fourth role, because the set has a different cardinality —
squeezing it into `dashboard_config` would mean partial unique indexes and a check constraint
that no longer reads as a rule. Two tables, each with one shape, is easier to be correct in.

**Why four settings and not three: completions and cancellations are different events and must
not be reported the same way.**

| Setting | Answers | Drives |
|---|---|---|
| **ECD date** | when is it *due* | flags 1–3 |
| **Completion date** | *when* did it finish | the Completed trend, "N completed this month" |
| **Complete status** | did it finish *successfully* | qualifies the above, so a cancellation never counts as a completion |
| **Inactive statuses** (set) | is work still happening | **suppresses flags** — no chasing a project nobody is building |

So "1 completed this month" is *completion date in the period **and** status = complete*. The
date alone would count a cancelled project whose date happened to be filled in; the status alone
can't say when.

**Flag suppression reads the inactive set, and the complete status is always treated as part of
it** whether or not the client ticks it. Leaving that to be configured invites the one
misconfiguration that matters — finished work nagging forever — and there is no sane deployment
where "complete" means work is ongoing.

The set is client-configured precisely because *"does On Hold count as inactive?"* has no
universal answer: a paused build is either still late or deliberately parked depending on how a
client runs their programme. That is theirs to decide, not ours to hard-code.

**The user is choosing a date field, and nothing else.** That is why the setting lives here
rather than as a role column on the field meta rows: pointing at a field is a dashboard
decision, so it belongs to the dashboard's own configuration. Field definitions are untouched,
and `admin_save.php`'s rule that static fields are owned by migrations stays absolute — no
carve-out, nothing to weaken. *(An earlier draft of this plan put the role on the field row and
needed exactly such a carve-out; that was the wrong shape and is why this is the right one.)*

Whether the chosen field is static or dynamic is invisible to the user, because the picker
lists **meta rows** either way — every field has one, static or not (`db/012`–`015` seeded the
static ones). Only the read path branches: a static field reads its entity-table column, a
dynamic one reads `*_field_values_date`. That branch exists in the load endpoints already.

`field_id` can't be a real foreign key — the meta tables are per-module, so there are four
possible parents. Validate on save instead: the field must exist in that module, be
`field_data_type = 'date'`, and be active. Config is deployment-wide, not per company; the
table shape leaves room to add a column later if that ever proves wrong.

Two roles today. Because the role is a *value* rather than a column, adding a third later is a
data change, not a migration.

#### Admin surface

A **Dashboard** config screen under admin. Each module listed with four controls — three
dropdowns (**ECD date**, **Completion date**, **Complete status**) and one multi-select
(**Inactive statuses** — "work is no longer happening"), each offering that module's own values
plus "Not set". Nothing more than that.

Show the complete status as already included in the inactive set — ticked and disabled, or
listed above it — so the rule is visible rather than surprising.

The date dropdowns list date fields only and the status controls list that module's statuses,
so the screen also tells a client *why* a module has no options — no date field exists yet —
which points them at the field admin rather than at support.

Statuses are read from the canonical status tables (`projects.project_status`,
`accounts.account_status`, `stocklists.stocklist_status`), which already exist, are already
populated, and share the `<module>_status_id` / `<module>_status_desc` shape — so reading them
is a prefix map, not a per-module branch. The backlog's **Admin "Statuses" management tab** item
would let clients edit those lists; this config only reads them, so it does not wait on it.

#### Wiring the admin page

Follow `admin_map_layers` exactly — it is the closest existing shape:

- **Route** in `www/fn/routes.php`: `'admin_dashboard' => ['module' => null, 'permission' =>
  'admin_fields', 'layout' => '1', 'head_nav' => [...html_header_nav_v2.php], 'nav_html' =>
  [''], 'html' => [...html_body_admin_dashboard.php], 'css' => ['css/admin.css'], 'js' =>
  [...admin_dashboard.js]]`. Note admin routes carry `'module' => null` and gate on
  `permission`, not on module enablement.
- **Card** on the admin home grid (`html/html_body_admin_home.php`, alongside the map-layers
  and distance-analysis cards).
- **Save endpoint** — if it reuses `admin_save.php`, add `admin_dashboard` to the `$allowed`
  list at `admin_save.php:1079`; that array is a whitelist and a new key is rejected silently
  without it.

#### Migration

Next number is **`db/019_*.sql`** (018 is the last applied). One migration creates both
`dashboard_config` and `dashboard_inactive_statuses`.

No special deploy handling: pull and migrate run back to back as a single upgrade step.

#### Degrading when a date isn't configured

Nothing errors and nothing is hidden behind a warning — the affected numbers simply don't
appear, and the lists fall back to Recents. **Design 5 already does this**: the card chain is
*issues → recents*, so if no ECD is configured the ECD-derived issues don't exist and the cards
fall through on their own. No extra branch to write.

What survives and what goes, with no dates configured:

| | Needs a configured date? |
|---|---|
| "ECD overdue, unsigned" · "ECD slipped" · "ECD approaching" | **yes — ECD** |
| Completed mode on the trend chart | **yes — completion** |
| "Stale — In Build" (uses `modified_datetime`) | no |
| "No premises in boundary" · "Not linked to a project" · "Active, no live projects" | no |
| Created mode on the trend chart (uses `created_datetime`) | no |
| Module card **Stages** line (uses status) | no |
| Recently opened, all module headline counts | no |

So a deployment with nothing configured still gets a working dashboard: four of the seven
exception rules, the Created trend, every card and every count. Worth stating in the admin
screen — "configure these to enable ECD and completion tracking" reads better than a page that
looks broken.

Two consequences to handle explicitly:

- **The Created/Completed toggle** has nothing to toggle to without a completion date. Hide the
  toggle and label the card "Premises created" rather than showing an empty Completed panel.
- **Configuration is per module**, so degradation is per module too: stocklists can have ECD
  rules while projects has none. The queue must not imply completeness it doesn't have.

### Phase 3 — Page build

Rebuild `html/html_body_dashboard.php`, `js/dashboard.js` and `css/dashboard.css` for the
chosen design. `dashboard.css` currently holds a single spinner-backdrop rule; the prototype's
component CSS moves in here, minus the `dx-` prefix and the prototype chrome.

Every widget must read the Phase 2 date configuration rather than any field id, and behave
correctly when a role is unset — see the degradation table there.

**Full width and genuinely responsive** — both found in browser testing on 2026-07-31, and both
were faults in the prototype rather than the design:

- **The page uses the full page width**, as every other GeoLynx page does. The prototype had
  been capping its mock-ups at 1400px centred, which quietly misrepresented all five layouts.
- **Grids must collapse on a phone.** `repeat(auto-fill, minmax(21rem, 1fr))` cannot shrink
  below its floor, so on a viewport narrower than the floor the track overflows and the whole
  page scrolls sideways. Use `minmax(min(21rem, 100%), 1fr)`. Likewise a card spanning the
  chart across the grid needs `grid-column: 1 / -1`, not `span 2` — `span 2` on a
  single-column grid invents a second column and overflows.
- Beyond the grids, the fixed-width parts need releasing at a narrow breakpoint: the nominal
  bar rows (fixed label + value columns squeeze the bar to nothing), the hero's right-aligned
  stat rail, the module card's fixed-width meter, and the segmented controls in the title bar
  and card heads.
- **Nothing may scroll the page sideways.** Wide content — tables, the trend chart — scrolls
  inside its own container.

Components are shared across the three designs — stat tile, compact list, exception row,
trend card, stage bar — so the losing designs' parts stay available. Build them as separate
render functions from the start rather than one page-shaped function.

The page must handle its own empty states per widget (the New user scenario), not one page-
level "no data" message. Every widget in the prototype has one; keep them.

**Settle the list lengths here, against real data** — deferred from Phase 0 by decision. The
prototype's three-rows-per-card and six-before-overflow are placeholders; so is the question of
whether a card caps its in-place expansion. Make them named constants rather than literals
sprinkled through the render functions, so tuning them is one edit.

**Design-specific work:**

- **Design 1** — the exception rules are the phase; the initial four are in "Flag definitions"
  above. Build them as one rule-per-function returning a common row shape, so a fifth is an
  addition rather than an edit.
- **Design 2** — the period (3/6/12 m) and scope filters in the title bar must reach every
  query on the page. Build them in from the start; retrofitting a filter row is the expensive
  version.
- **Design 3** — the card grid renders from the module/permission list, so a module the user
  cannot see produces no card and no query. Cards ship independently; land Projects and
  Wayleave first.
- **Design 4** — all of the above except the tile strip, plus one layout decision: whether the
  module grid stays a full grid below the fold or collapses to a single strip. Build the queue
  and the rail first; the grid can land card by card behind them.

#### Flag definitions — **defined 2026-07-30**

The initial set. The prototype now renders these and nothing else.

| Priority | Flag | Criteria | Badge | Label | Metric | Depends on |
|---|---|---|---|---|---|---|
| 1 | ECD is overdue | Still active, ECD in past | **Overdue** | `<date> overdue` | days past, e.g. "18 days" | configured **ecd** + **inactive set** |
| 2 | ECD is moving | Still active, ECD moved ≥ 3 times in 90 days | **Risk** | `<date> moved N times` | net movement, e.g. "+62 days" | ecd + inactive set + **date history** |
| 3 | ECD due in 7 days | Still active, ECD within 7 days | **Check** | `<date> due soon` | "in N days" | ecd + inactive set |
| 4 | Stale record | No status change in 30 days | **Check** | `Stale — no status change` | days since, e.g. "47 days" | **status history** |

`<date>` is the **configured field's own name**, not the literal word "ECD" — see assumption 2
below. Projects will read "ECD overdue"; a stocklist whose ECD-role field is called *Signoff
Date* reads "Signoff Date overdue".

**"Not complete" resolved to "still active": the record's status is not in the configured
inactive set** (which always includes the complete status). Two things follow that the original
wording would have got wrong — a record marked complete by status with no completion date filled
in raises no flag, and a **cancelled** record stops being chased instead of nagging forever
about an ECD nobody intends to meet.

**Priority earns its keep twice**: it orders the queue, and it decides which flags win a card's
three rows when a module has more than three — so a card shows its worst, not its most recent.
It also partly restores the cross-module severity design 5 gives up, since the queue is
priority-sorted across every module even though the cards are grouped by module.

**Badge vocabulary follows from the flags: Overdue · Risk · Check · Recent.** "Slipping" became
**Risk**; **Tidy** went unused — nothing in the set is housekeeping — so it folds away, which
answers the open question. Flags 3 and 4 share the Check badge and are told apart by label and
icon: the badge carries severity, the label carries what happened.

##### How the criteria resolved — all three closed 2026-07-30

1. ~~"Not complete" = the completion date is empty.~~ **Closed 2026-07-30.** Flags suppress on
   the configured **inactive-status set**; completions reporting uses the **completion date +
   complete status**. Cancellations therefore stop being chased without ever being counted as
   completions. See Phase 2.
2. ~~Are flags 1–3 projects-only?~~ **Closed 2026-07-30: generic to any module with an ECD
   configured.** The roles are *functional*, and only the wording differs per module and per
   client — an account or stocklist signoff date effectively means "it is now OK to start
   projects from this"; opportunities will get an approval-to-plan step with its own dates.
   Same rule, different noun.

   **Consequence: flag labels must not hard-code the word "ECD".** Derive the noun from the
   configured field's `field_name` on its meta row — "ECD overdue" for a project, "Signoff date
   overdue" for a stocklist. That costs nothing (the name is already there) and is the
   difference between the flags reading as the client's own language or as our jargon.

3. ~~"Moved ≥ 3 times" over what window?~~ **Closed 2026-07-30: a rolling 90-day window.** So
   the flag reads "moved 3+ times in the last 90 days" and clears itself once a date settles,
   rather than condemning a long-running record forever on history it has moved past.

   Unchanged from the original reading: the count **excludes the first time the date was set**
   (setting is not moving) and counts moves in **either direction** — pulling a date in
   repeatedly is as much a churn signal as pushing it out.

   **Do not reuse `reports.vw_project_ecd_change` as-is**: it hard-codes a 60-day window *and*
   `field_id = 8`, so it would silently give "3 times in 60 days" on one hard-coded field. This
   needs its own query against the configured field. 90 days joins 7 days and 30 days as a
   tunable threshold.

##### Data sources

- Flags 1 and 3 read the configured ECD date and the configured completion date. Cheap.
- Flag 2 needs **date-field history** — `*_field_values_date_history` for a dynamic ECD, or
  the entity history table for a static one. `reports.vw_project_ecd_change` already computes
  a `change_count` per project, but hard-codes `field_id = 8`; Phase 2 removes that, so this
  flag and that view should end up reading the same configured field.
- Flag 4 needs **status history**, not `modified_datetime` — a project edited daily can still
  be stale in status, which is the point of the rule. Available from the entity history tables
  (migration 017 put triggers on projects/accounts/stocklists; `trg_projects_history` predates
  the baseline). Worth confirming the history depth is sufficient before relying on it — a
  status that last changed before triggers existed has no row to find.

All four degrade to silence when their dependency is unset, per the Phase 2 table. Flags 1–3
need an **ECD field** and the **inactive set** — with no ECD they don't exist for that module
and the cards fall through to Recents. With an ECD but an empty inactive set they would fire on
everything including finished work, so treat that as unconfigured too and stay silent: a wrong
flag is worse than a missing one. Flag 4 needs neither, only status history, so it works on a
module with no dashboard config at all.

Thresholds (7 days, 30 days, 3 moves) will need a second pass once real data is behind them.

##### Built configurable, 2026-07-31

The four flags live in `dashboardFlagRegistry()` (`www/fn/dashboard_load.php`) — one entry each
carrying name, priority, badge, dependencies and threshold shapes, plus a builder function.
Clients enable/disable them per module and tune the thresholds via Admin → Dashboard, stored in
`public.dashboard_flag_config` (`db/020`). An absent row means *enabled with defaults*, so the
dashboard works unconfigured and a flag added to the registry later is live everywhere without
a backfill.

**What is configurable and what is not.** The flags themselves are code: their conditions, SQL
and meaning. The database holds only which are on and what their numbers are. That is a
deliberate line — user-*defined* flags would mean compiling client-composed conditions into
SQL, with the injection surface and unbounded query cost that implies.

**The two likely v2 extensions, and the one to resist**, are recorded under **Dashboard** in
`docs/improvement-opportunities.md`: multiple instances of a flag per module, and status-scoped
flags. The one to resist is phase-specific dates (survey/plan/build ECDs with per-phase rules)
— that is `docs/task_system_plan.md`'s job, where a task instance carries its own ECD and only
exists once the workflow reaches it. Flags stay record-level; phases belong to the task system.

#### Build notes per design

- **Design 5** — the same exception rules as design 1, but grouped by module rather than
  sorted by severity across all of them, so slightly cheaper. Build the card's row-filling
  chain as one function (issues → recents, capped at three, deduped **by entity id**) and every
  card inherits it. The queue reuses `.hx-panel` and its existing collapse
  handler, so the only new work there is the severity tint and the roll-up counts for the head.
  Note the head's counts come from the same result set as the rows — one query, not two.

### Phase 4 — Chart tokens and encoding

Two chart ramps go into `custom.css` alongside the existing `--gl-*` tokens. Neither is
hand-picked; both were validated (monotone lightness / adjacent ΔL for the ordinal ramp;
OKLab ΔE under simulated protanopia and deuteranopia for the categorical set).

| Purpose | Tokens | Values |
|---|---|---|
| **Ordinal** — ordered stages, part-to-whole | `--gl-ord-1..5` | `#2E1D4A` `#452C6E` `#5A3D8A` `#7C5FA8` `#A48FC6` |
| **Categorical** — series identity only | `--gl-cat-1..4` | `#5A3D8A` `#C2571A` `#1E70B8` `#0F8A5F` |

Ordinal: monotone lightness, adjacent ΔL ≥ 0.06, light end 2.79:1 on white. Categorical:
worst adjacent pair ΔE 17.0 (deuteranopia) / 18.0 (normal vision), all four ≥ 3:1 on white,
slot 1 is the app accent.

Status keeps `--gl-ok` / `--gl-warn` / `--gl-bad` and never doubles as a series colour. Those
three fail a categorical check against each other (warn↔bad ΔE 13.2 under normal vision) —
which is acceptable only because status always ships with an icon and a word, as it does in
the prototype's exception rows. If a status is ever rendered as hue alone, that check becomes
a real failure.

Most of this dashboard needs **no** categorical palette: stages are ordered, the trend is a
single series, account bars are one hue. The categorical set exists for the charts that come
later, and so the next person doesn't invent one.

Encoding:

- **The trend is one series** — total premises — so it takes no legend; the card title names
  what is plotted, and a legend box with one swatch just restates it.
- Created and Completed become one chart with a toggle, sharing a scale. The interesting
  quantity — the gap between them — gets written out as a sentence.
- Datalabels come off. The y-axis carries the values, and every trend card gets a **table
  view** toggle so no number is reachable only by hovering.
- Keep the existing month drill-down modal; it works and is the best thing on the page today.

## Testing checklist

Per design, at 1080p and at a narrow window:

- [x] Prototype reviewed and a design chosen *(Phase 0 — design 5, 2026-07-30)*
- [x] Module card second line chosen *(Phase 0)* — **Stages**, agreed at review
- [x] Overflow expands in place rather than linking out — agreed at review
- [ ] List lengths tuned against real data *(Phase 3, deliberately deferred)* — rows per card,
      rows before the queue's overflow control, and whether a card caps its own expansion
- [x] Queue default state chosen *(Phase 0)* — collapsed on load; no panel at zero items
- [ ] Zero items renders **no panel and no gap** — check the New user scenario and a real
      account whose queue has been cleared
- [ ] Collapse state persists across page loads (a user who opens the queue should not have to
      open it again next visit) — decide whether it persists at all, given the agreed default
      is collapsed
- [x] **Flags defined** *(2026-07-30)* — four to start; badge vocabulary settled as
      Overdue · Risk · Check · Recent, Tidy dropped
- [ ] Flag labels read the **configured field's name**, not the word "ECD" — check a module
      whose ECD-role field is called something else
- [ ] Flag 2 counts moves in a **rolling 90 days**, not for the record's lifetime, and clears
      once a date settles
- [ ] Queue is sorted by flag priority across all modules; a card with more than three flags
      shows its three highest-priority ones, not its three most recent
- [ ] Flag 4 reads **status** history, not `modified_datetime` — a record edited daily with an
      unchanged status must still flag as stale
- [ ] Flag 2's move count excludes the initial set of the date, and history depth is deep
      enough for the count to be meaningful
- [ ] Item row: names wrap to two lines and truncate cleanly at the third; check the Heavy
      scenario, where names run past 60 characters, and a narrow window
- [ ] If design 5: a record that is both flagged and recently opened appears **once** per
      card, matched on entity id — check a stocklist and a project, and check two records
      that share a name
- [ ] Stages: the micro-key under the strip stays legible on a narrow card — five stages wrap
      to two lines at 21rem — and opportunities (no status) falls back cleanly, and "nothing due this month" is distinguishable from "behind"
- [x] **Full page width**, matching every other GeoLynx page — no centred max-width column
- [ ] **Phone (360–390px)** — the module grid is one column, the wide chart card spans it
      rather than overflowing, and **the page does not scroll sideways at any width**
- [ ] Tablet / narrow desktop — the two-column layout collapses cleanly at its breakpoint
- [ ] Typical scenario — everything populated, nothing overflows
- [ ] New user — every widget has a sensible empty state, no bare zeroes with no explanation
- [ ] Heavy — long names truncate rather than wrap or overflow; seven-figure numbers fit the
      tiles; the exception list caps and links to a full view
- [ ] Restricted — disabled modules produce no widget and no query; read-only badge shows;
      nothing links to a page the user cannot open
- [ ] Scope switch (Mine / Everything) changes every number on the page — and for a
      non-main-company user, "Everything" returns the same set their list pages do

**Date configuration** *(Phase 2)*:

- [x] Each module can have an ECD field, a completion field, a complete status **and a set of
      inactive statuses** set, and unset again
- [ ] The status controls list that module's own statuses; the check constraint rejects a
      `dashboard_config` row carrying both a `field_id` and a `status_id`
- [ ] A record whose status is the complete status raises no ECD flag, **even when its
      completion date is empty**, and **even if the client never ticked complete as inactive**
- [ ] A **cancelled** record raises no ECD flag and **is not counted in "N completed this
      month"** — the two must not move together
- [ ] An ECD configured but an **empty inactive set** produces no flags at all, rather than
      flagging every finished record
- [ ] A **static** date field is selectable and reads correctly alongside dynamic ones — the
      picker shows meta rows, so the two look identical to the user
- [ ] **Wayleave does not appear** on the config screen or the dashboard, and nothing errors by
      its absence
- [ ] Field definitions are untouched by saving config, and static fields remain uneditable in
      the field admin (the guard should not have moved at all)
- [ ] Only `date`-type fields are offered, and only active ones
- [ ] Creating a new date field in a module makes it appear in that module's dropdowns
- [ ] Deactivating a configured field degrades that module to Recents and is flagged on the
      config screen, not left to disappear silently
- [ ] With **nothing** configured: no errors, no empty panels; ECD rules and the Completed
      trend mode are absent, the Created/Completed toggle is gone, and cards fall through to
      Recents. Four exception rules, every card and every count still work
- [ ] With **one module** configured: that module shows ECD rules, the others don't
- [ ] **Opportunities** shows its controls disabled with an explanation, not four empty selects
- [x] The admin page appears on the admin home grid and is reachable only with the
      `admin_fields` permission
- [ ] Config load/save modes are in the `$allowedModes` whitelist **and** the mode → permission
      map, the way `admin_load.php` does it — an unlisted mode must be refused, not run
- [ ] Chart: single series, no legend box; table view matches the chart; drill-down modal
      still opens on a column click
- [ ] Charts distinguishable in greyscale, and status never conveyed by hue alone
- [ ] Recently opened still logs and orders correctly for all five entity types
- [ ] Page load time against a realistic dataset — every widget query timed individually

## Post-signoff fix — the count and the strip now share one predicate (2026-08-04)

Found by Dave while testing the admin reorganisation: a module card's headline number and
the stage strip under it were built from **two different populations**, so they did not add
up and the card gave no way to tell why.

- The count said "not in the inactive set" (`dashboardActiveSql`).
- The strip said "not inactive, **or** completed this month".

Two causes, fixed two ways. **Statusless records** — in the count, in no segment — were
fixed at source by `db/023_default_created_status.sql`, which gives every module a `Created`
status and makes the column NOT NULL (see
`docs/2026-08-03-admin-status-management.md`). **This month's completions** — in the strip,
not in the count — were fixed by widening the count to match the strip, so the headline is
now current work *plus* anything finished this month and its label reads **"current"**
rather than "live". The strip is therefore the headline broken down, which is how a bar
under a number gets read whatever the labels say.

The structural repair is `dashboardCurrentSql()`: one predicate, read by both the count and
the strip, where each previously built the rule for itself. That duplication was the actual
defect; the wrong numbers were a symptom.

**It is deliberately not a change to `dashboardActiveSql()`.** That answers "is work still
happening", which is what the flags need — a project completed last week must not start
raising overdue flags again just because it is still on the card this month. The two
definitions genuinely differ; the bug was the count using the flags' one when it wanted the
strip's.

**Consequence, accepted:** the headline is now month-to-date and steps down on the 1st as
the previous month's completions fall out. It is no longer a drift-free measure of live work.

**A card drops the "current" qualifier only when `inactive_statuses` is empty** — and
`getDashboardConfig()` folds the complete status into that set, so it takes both an unset
inactive list *and* an unset complete status. Setting either qualifies the count.

## Parked — cross-module risk flags

**Dropped from this version 2026-07-30, to get v1 delivered.** The rule was sound; the
dependency wasn't available. Un-park when the **"Attach projects to a wayleave"** backlog item
lands (`docs/improvement-opportunities.md`) — everything else here is already specified.

The rule: *a project whose ECD is close, where a wayleave attached to it is still unsigned, is
a project at risk.* The wayleave is the cause, the project is what's in trouble, so the flag
belongs on the project's card.

**The principle is worth keeping even though the rule isn't shipping — it is the opposite of
the parked insights: a cross-module flag fires on positive evidence, never on absence.** A
linked agreement in a bad state is evidence. A project with *no* agreement is not: you cannot
tell "doesn't need one" from "needs one and hasn't got one" without proving the requirement,
which is what the MDU-without-wayleave analysis was for. Absence-driven checks belong with the
insights, not the flags.

| Wayleave module | Project has a linked agreement | Agreement complete | Flag? |
|---|---|---|---|
| disabled | — | — | **no** — the app cannot know |
| enabled | no | — | **no** — absence is not evidence |
| enabled | yes | yes | no |
| enabled | yes | no | **yes**, if the project's ECD is close |

Also settled while specifying it, so it doesn't need re-deciding: module *enablement* is
deployment-level, but **user permission** needs the same gate for a different reason — a flag
must not reveal wayleave state to someone without wayleave read access, so it checks
`getModulePermission()` as well as `app_modules`. And the rule composes entirely from Phase 2
configuration ("ECD is close" reads the project's configured **ecd** field, "complete" reads
wayleave's configured **complete status**), so it degrades to silence like everything else.

**Why it can't ship yet.** Nothing links a project to a wayleave: `wayleave.agreements` has
`account_id`, `company_id` and geometry but no `project_id`, and there is no link table. The
relationship can only be *inferred* today — intersecting agreement polygons with project
boundaries, or overlapping UPRN sets — and an agreement polygon clipping a boundary does not
mean the agreement is *for* that project; a neighbouring landowner's deed produces the same
overlap. A risk flag that cries wolf is worse than no flag, because people stop reading the
whole queue. So the flag must read the **asserted** link, not a derived match.

**Open when it returns:** the badge vocabulary is Overdue · Slipping · Check · Tidy · Recent,
and "at risk, caused elsewhere" is not comfortably any of them. A dedicated **At risk** badge
may be the honest answer.

## Parked — cross-module insights

**Dropped from this version 2026-07-30.** The candidates were interesting but not obviously
*useful*: reviewing them provoked either "why is that important?" or "that needs a more
detailed tool in its own right". Both are fair, and neither is answerable from here — the way
to find the insights worth showing is to watch clients use the refreshed dashboard and see what
they go looking for. Shipping speculative insights would also set the wrong precedent for a
panel that is meant to earn its space.

Note the two reactions are different verdicts and want different follow-ups: *"why is that
important"* means the insight is weak; *"that needs its own tool"* means the insight is real
but a dashboard line is the wrong container for it.

The candidates, kept so the analysis isn't repeated:

- **MDU blocks in live projects with no wayleave agreement** — a spatial join over every UPRN
  in every live project (`vw_projects_list`'s `prems` CTE) crossed with the four
  `wayleave.vw_uprns_from_*` views. The strongest of the three and the clearest example of
  "needs its own tool": it is really a worklist, not a number.
- **Premises within N metres of a live boundary that are in no project** — expansion
  candidates, via `projects.project_nearest_neighbours`. Overlaps the existing distance
  analysis tool, which is the more detailed tool it wants to be part of.
- **Net delivery position** — completions minus creations over a period. Cheap enough to add to
  the trend card whenever it is wanted; it needs no roll-up.

If any of these return, the first two want a materialised view or a nightly roll-up carrying a
`calculated_datetime` shown on the card. A stale number that says when it was computed is
honest; a live query that takes nine seconds is not.

**Un-park when** real usage shows what people actually chase — not before.

## Risks

- **The exception rules are the product, in design 1.** The layout is easy; agreeing what
  "stalled" means, and keeping the list short enough to be read, is the actual work. A queue
  people stop reading is worse than no queue.
- **Scope creep into a reporting tool.** The dashboard should start work, not replace the
  distance-analysis and report pages. Anything that wants a filter panel belongs elsewhere.
- **Giving opportunities an owner and a status** touches the static-field meta chain
  (migration, `$staticFields`, load view, admin listing) and the opportunity editor.
- **v1 carries no dependency on unbuilt work** — both cross-module features are parked, so
  nothing in the build waits on the wayleave module. Keep it that way: the fastest way to
  stall this plan again is to accept a rule that needs a relationship the schema doesn't have.
- **`www/test.php` is the design's only record.** The prototype is the spec for design 5 — the
  item row, the fallback chain, the collapse behaviour, the responsive rules. Don't delete it
  until the built page is signed off, and expect to update it if the build diverges, or the two
  will disagree with no way to tell which is right.
- **A configured field can later be deactivated.** GeoLynx disables reference values rather
  than deleting them, so a config row can end up pointing at an inactive field. Treat that as
  unset — the dashboard degrades to Recents, as designed — and surface it on the config screen
  rather than failing silently, or the client sees numbers quietly disappear.
- **`reports.vw_project_ecd_change` hard-codes `field_id = 8`** — the same anti-pattern this
  phase removes from the dashboard, one layer down in a database view. It should read the
  configured ECD field, or it will disagree with the dashboard silently. Out of scope here, but
  it should not be left as the last hard-coded date in the tree.
- **Per-user layout is implied but not planned.** Design 3's "Customise" button is drawn in
  the prototype. If it is wanted, it is its own plan — a layout table, a default, and a reset.
- **`www/test.php` currently holds this prototype.** The previous occupant (eight colour
  schemes with the protanopia/deuteranopia simulator) is at `git show 7956985:www/test.php`
  and should be restored there when the admin-set status colours item comes round —
  `docs/2026-07-27-field-safe-colour-scheme.md` Phase 4 records that the page is kept as the
  standing prototype surface.
