Files
LynkedUpPro_CRM/docs/superpowers/plans/2026-05-29-plan3-pipeline-depth-analytics.md
T

267 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 3 — Pipeline Depth + Computed Analytics
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`). Follow the execution protocol (`2026-05-29-execution-protocol.md`) — sequential on shared files, parallel only where lanes are disjoint.
**Goal:** Make the pre-sale pipeline read like a busy real company, realize the 10 demo-case archetypes as coherent threads (to the extent the current data model supports — lifecycle mechanics are Phase 4), and add a **computed `src/data/selectors.js`** analytics layer wired into the owner & admin dashboards so KPIs can never drift from the records.
**Architecture:** Two data-authoring tasks on `mockStore.jsx` (sequential) raise the 28 pre-sale leads to realistic quality and curate/tag the 10 scenario archetypes (adding the one *lost* lead). Then a new pure-function module `src/data/selectors.js` derives every dashboard metric from the live store arrays; the owner (`OwnerSnapshot.jsx`) and admin (`Dashboard.jsx`) screens import it and render pipeline/funnel/revenue/commission/storm panels. A small independent fix repairs three broken route-path strings in `App.jsx` so pre-sale leads are clickable.
**Tech stack:** React 18, Vite 7, mock React-context store, pnpm. No test harness → selectors are verified with throwaway `node` assertion scripts over fixtures (pure functions make this trivial) **and** a reconciliation check that each selector over the real data equals an independent hand-sum. Nothing test-related is committed (repo has no test dir; persistent unit tests are a deferred, out-of-scope enhancement).
**Spec/master:** `docs/superpowers/2026-05-29-MASTER-PLAN.md` §2 (compute-don't-hardcode), §12 + Appendix B (10 scenarios), §14 (coherency), Appendix A.1/A.2 (schemas), Appendix G Plan 3.
---
## Current state (verified)
- **44 kanban leads total**; 16 are won (`kl_023038`, now `PRJ-2026-013…028` via Plan 2). The remaining **28 are pre-sale**, distributed across `kc_new` / `kc_contacted` / `kc_appt` / `kc_estimate` (+ optional `bucketId` `kc_stuck`/`kc_followup`).
- **60 sequential fake phone numbers** (`(972) 555-0NNN`) across the store — these read as obviously fake on screen and must be replaced with varied, realistic numbers.
- **No `kc_lost` / lost-lead representation exists** — scenario #3 (up-scope → declined) needs one.
- **`OwnerSnapshot.jsx`** already reduces over `projects` for `totalBudget`/`totalSpent`/`avgHealth`/status counts, but computes **no** pipeline value, funnel, win-rate, revenue/profit, commission-by-rep, sub performance, or storm attribution; it reads `owner.portfolioSize`/`owner.riskScore` as static fields. It does **not** look at `kanbanLeads`.
- **`Dashboard.jsx`** (admin/field) and **`LeaderboardPage.jsx`** exist; no shared analytics module.
- **`src/data/selectors.js` does not exist.**
- **`App.jsx` route bug:** the pre-sale lead-detail routes are written with backslashes and a missing slash — `path="\emp\fa\leads:leadId"` (line ~129), `path="\owner\leads:leadId"` (~211), `path="\admin\leads:leadId"` (~341). React Router never matches these, so clicking a pre-sale lead 404s. Correct form: `/emp/fa/leads/:leadId`, `/owner/leads/:leadId`, `/admin/leads/:leadId`.
## Scope boundary with Phase 4 (read before authoring)
Appendix B's table is **illustrative**. Some scenario beats require models that **do not exist until Phase 4**: damage/final **inspections**, **estimate version** history, scope-variance badges, and the **On Hold** lifecycle state. Plan 3 authors only the **presently-supported** fields of each archetype (customer, address, value, stage via `columnId`/project `status`, financials, storm attribution, commission, activity notes) and **flags** the Phase-4-dependent beats as deferred. Do **not** add `inspections[]` / `estimateVersions[]` / on-hold mechanics in Plan 3.
---
## Lane / ownership map (for safe parallelism)
| Task | Lane (exclusive files) | Phase |
|------|------------------------|-------|
| T1 Pre-sale lead realism | `src/data/mockStore.jsx` | A (sequential) |
| T2 Scenario curation + lost lead | `src/data/mockStore.jsx` | A (sequential, after T1) |
| T3 `selectors.js` (new) | `src/data/selectors.js` | B (after A committed) |
| T4 Route-path fix | `src/App.jsx` | B (parallel, independent) |
| T5 Wire owner dashboard | `src/pages/owner/OwnerSnapshot.jsx` | B (parallel, after T3) |
| T6 Wire admin dashboard | `src/pages/Dashboard.jsx` | B (parallel, after T3) |
| T7 Verify | (read-only) | C |
**Phase A (T1, T2) strictly sequential** — both edit `mockStore.jsx`. **T3** runs after A commits (so reconciliation reflects final data). **T4** is independent and may run any time. **T5/T6** run after T3 commits (they import it); they edit disjoint files so they may run in parallel with each other and with T4. **T7** last.
---
## Task 1: Pre-sale lead realism
**Files:** `src/data/mockStore.jsx` only (the `KANBAN_LEADS_INITIAL` pre-sale entries — every `kl_###` **without** a `projectId`, i.e. not `kl_023038`).
**Goal:** every pre-sale lead reads like a real prospect. Per the schema (Appendix A.2): `{ id, name, address, phone, email, jobType, insuranceType, value, stormSource?, assignedAgentId, assignedAgentName, columnId, bucketId?, notes, createdDate, activity[{from,to,by,date,note}] }`.
- [ ] **Step 1:** Enumerate pre-sale leads: `git grep -n "id: 'kl_0" -- src/data/mockStore.jsx` and exclude `kl_023``kl_038`. Confirm the count (~28) and note each one's current `columnId`.
- [ ] **Step 2 — kill the fake phones.** Replace every sequential `(972) 555-0NNN` phone in the pre-sale leads with a **varied, realistic** number. Use a mix of real Plano/DFW area codes (972, 469, 214, 945) and non-sequential last-four digits. No two adjacent leads share a pattern. (Win/project records may also carry these fakes — fixing those too is in-lane and encouraged, but the pre-sale leads are the required surface.) Verify after: `git grep -cE "\(972\) 555-0[0-9]{3}" -- src/data/mockStore.jsx` drops substantially toward 0.
- [ ] **Step 3 — realistic identities.** Each pre-sale lead has: a real-sounding **customer name** (Texan/DFW variety, no "Lead One"), a real **Plano/DFW address** (street + ", Plano, TX <zip>"), an **email** derived from the name (e.g. `b.ochoa@gmail.com`, mix gmail/yahoo/outlook), a **jobType** from the company's trades (Roof Replacement, Roof + Gutters, Siding, Gutter Repair, Window Replacement, Inspection), an **insuranceType** (`Insurance` | `Retail`), and a realistic **value** (pre-sale estimate range $4k$45k; `kc_new` may have `value: null` or a rough number; `kc_estimate` should have a firm value).
- [ ] **Step 4 — real dates + activity threads.** Each lead's `createdDate` is a real 2026 date consistent with its stage (newer leads later). Each `activity[]` is a coherent thread of `{from, to, by, date, note}` transitions ending at the lead's current `columnId` stage, `by` = the lead's `assignedAgentName` (a canonical field agent e1e5), dates strictly increasing and ≤ `DEMO_TODAY` (2026-05-29). Follow the existing pre-sale lead style (see any current `kl_0xx` with an `activity` array).
- [ ] **Step 5 — storm attribution + stage coherence.** Set `stormSource: true` on the subset of leads that were canvassed after a storm (roughly the storm-canvassing ones — keep ~4060% storm-sourced for a storm-season company; the rest referral/retail/web). Ensure a **coherent funnel shape**: more leads in earlier stages than later (e.g. new ≥ contacted ≥ appt ≥ estimate is a reasonable but not strict gradient), so the pipeline reads like real attrition.
- [ ] **Step 6 (verify):** `npm run build``✓ built`, zero errors. Spot-grep that no pre-sale lead is missing `name`/`address`/`value`/`activity`. `git grep -cE "\(972\) 555-0[0-9]{3}"` near 0.
- [ ] **Step 7:** commit `git commit -am "feat(data): realistic pre-sale lead identities, values, dates, and storm attribution"`.
## Task 2: Curate the 10 scenario archetypes + add the lost lead
**Files:** `src/data/mockStore.jsx` only. **Runs after T1.**
**Goal:** ensure all 10 demo-case archetypes (Master §12) are represented and coherent across the existing ~28 projects + 28 pre-sale leads, using only currently-supported fields. Tag each so the demo can find them, and add the one missing archetype (the lost lead).
- [ ] **Step 1:** Map each of the 10 archetypes to an existing record (project or lead) that best fits, by reading the current `projects` and pre-sale leads:
- #1 Storm-driven win on track → an active storm-sourced project (e.g. `PRJ-2026-001`).
- #2 Insurance up-scope → approved → a signed/active insurance project *(inspection/supplement beat is Phase 4 — author the customer/value/stage now)*.
- #3 Up-scope → **declined (lost)****new lead, added in Step 3.**
- #4 Smaller/different than claimed → a small completed repair project.
- #5 Multiple revisions/negotiation → a scope-approved project *(estimate-version history is Phase 4)*.
- #6 Over-budget / margin loss → a project where `committedCost > budget` (negative margin). If none exists, adjust one in-progress project's `committedCost`/change-order so committed exceeds budget **while keeping `Σbreakdown.committed == committedCost`** and `committedCost ≥ actualCost`; recompute `variancePercent`.
- #7 Large commercial, multi-phase → the largest commercial project (e.g. `PRJ-2026-007`) with a phased `paymentSchedule` and ≥2 subs in `subcontractorIds`/`teamMembers`.
- #8 Near-complete healthy → a project at ~8595% with most milestones done, some invoices pending.
- #9 Completed, all paid → a `status: 'completed'`, 100%, all `paymentSchedule` paid, all invoices paid.
- #10 Disputed / on-hold → a low-health project with a dispute note in `activityTimeline`/`issueLog` and paused payments *(the formal On Hold state machine is Phase 4 — for now use `healthScore` ~30 + a dispute entry + a `pending` paymentSchedule and a `notes`/issue describing the dispute)*.
- [ ] **Step 2:** Add a machine-findable tag to each chosen record: `demoScenario: <1..10>` (a harmless extra field; dashboards/selectors ignore it; it lets the demo and verification locate each archetype). Exactly one record per scenario number.
- [ ] **Step 3 — add the lost lead (#3).** Append a pre-sale lead to `KANBAN_LEADS_INITIAL`: `{ id: 'kl_045', name: 'Greg Lindfors', address: '6601 Phoenix Pl, Plano, TX 75023', phone: <realistic>, email: 'g.lindfors@gmail.com', jobType: 'Roof Replacement', insuranceType: 'Insurance', value: 18000, stormSource: true, assignedAgentId: 'e1', assignedAgentName: 'Cody Tatum', columnId: 'kc_lost', outcome: 'lost', demoScenario: 3, notes: 'Inspection found major damage; revised estimate to $18k. Customer declined — sticker shock after deductible.', createdDate: '2026-04-02', activity: [ ...a thread that reaches Estimate Sent then a transition to 'Lost' with a decline note... ] }`. Introduce the `kc_lost` stage as a recognized lead state (it does not need a kanban column to render; it is used by the win-rate selector and is filtered OUT of the open-pipeline funnel).
- [ ] **Step 4 — reconcile.** Any project touched in Step 1 (#6, #10) must still satisfy the financial invariants (Master §14): `Σbreakdown.allocated == budget`; `Σcommitted == committedCost`; `Σactual == actualCost == spent`; `committedCost ≥ actualCost`; `variancePercent == round((actualCostbudget)/budget×100,1)`; paid invoices ≤ actualCost. (#6 deliberately has negative margin: `committedCost > budget` is allowed; the invariant is on the breakdown sums, not on margin sign.)
- [ ] **Step 5 (verify):** `npm run build``✓ built`. `git grep -c "demoScenario:" -- src/data/mockStore.jsx` → 10. `git grep -n "columnId: 'kc_lost'"` → 1.
- [ ] **Step 6:** commit `git commit -am "feat(data): curate 10 demo-case archetypes (tagged) and add the lost lead (#3)"`.
## Task 3: Create `src/data/selectors.js` (computed analytics)
**Files:** Create `src/data/selectors.js`. **Runs after Phase A is committed.**
Pure functions over the store arrays — **no React, no import from mockStore** (callers pass arrays in). This is what guarantees dashboards can't contradict records: there is exactly one computation, reused everywhere.
- [ ] **Step 1:** Create the file with these exact functions:
```js
// src/data/selectors.js
// Pure analytics derived from the live store arrays. No React; no imports from mockStore.
// Callers pass the arrays they already have from useMockStore(); these functions never read context.
// Open pre-sale pipeline stages, in funnel order. Won/in-progress/complete are projects, not pipeline.
export const PRESALE_STAGES = ['kc_new', 'kc_contacted', 'kc_appt', 'kc_estimate'];
const sum = (arr, f) => arr.reduce((s, x) => s + (f(x) || 0), 0);
const isPresale = (l) => PRESALE_STAGES.includes(l.columnId);
// Funnel: count + $ value at each open stage.
export function pipelineFunnel(kanbanLeads = []) {
return PRESALE_STAGES.map((stage) => {
const leads = kanbanLeads.filter((l) => l.columnId === stage);
return { stage, count: leads.length, value: sum(leads, (l) => l.value) };
});
}
// Total open pipeline $ = Σ value of pre-sale leads.
export function pipelineValue(kanbanLeads = []) {
return sum(kanbanLeads.filter(isPresale), (l) => l.value);
}
// Win rate % over DECIDED leads: won (has projectId / columnId in signed+) vs lost (kc_lost | outcome 'lost').
export function winRate(kanbanLeads = []) {
const won = kanbanLeads.filter((l) => !!l.projectId).length;
const lost = kanbanLeads.filter((l) => l.columnId === 'kc_lost' || l.outcome === 'lost').length;
const decided = won + lost;
return { won, lost, decided, rate: decided ? Math.round((won / decided) * 100) : 0 };
}
// Revenue recognized = Σ client payments received (paymentSchedule status 'paid'); cost = Σ actualCost.
export function revenueSummary(projects = []) {
const revenue = sum(projects, (p) =>
sum((p.paymentSchedule || []).filter((ps) => ps.status === 'paid'), (ps) => ps.amount));
const cost = sum(projects, (p) => Number(p.actualCost ?? p.spent ?? 0));
const commissions = sum(projects, (p) => Number(p.commission || 0));
return { revenue, cost, grossProfit: revenue - cost, commissions, netProfit: revenue - cost - commissions };
}
// Commission grouped by the project's sales rep. resolvePerson(id)->{name} optional, for display.
export function commissionByRep(projects = [], resolvePerson) {
const byRep = {};
for (const p of projects) {
const repId = p.salesRepId
|| (p.teamMembers || []).find((t) => /Sales|Estimator/i.test(t.jobRole || ''))?.userId;
if (!repId) continue;
byRep[repId] = (byRep[repId] || 0) + Number(p.commission || 0);
}
return Object.entries(byRep)
.map(([repId, total]) => ({ repId, name: resolvePerson?.(repId)?.name || repId, total }))
.sort((a, b) => b.total - a.total);
}
// Subcontractor performance: project count + total fees + simple on-time signal, per sub.
// tasks = MOCK_SUBCONTRACTOR_TASKS; resolvePerson optional for names.
export function subcontractorPerformance(projects = [], tasks = [], resolvePerson) {
const acc = {};
for (const p of projects) {
for (const subId of p.subcontractorIds || []) {
acc[subId] = acc[subId] || { subId, projects: 0, tasks: 0, fees: 0 };
acc[subId].projects += 1;
}
}
for (const t of tasks) {
const id = t.subcontractorId;
if (!id) continue;
acc[id] = acc[id] || { subId: id, projects: 0, tasks: 0, fees: 0 };
acc[id].tasks += 1;
acc[id].fees += sum(t.fees || [], (f) => f.amount);
}
return Object.values(acc)
.map((r) => ({ ...r, name: resolvePerson?.(r.subId)?.name || r.subId }))
.sort((a, b) => b.projects - a.projects);
}
// Storm attribution: share of pipeline value and recognized revenue traceable to storm-sourced leads.
export function stormAttribution(kanbanLeads = [], projects = []) {
const stormLeadIds = new Set(kanbanLeads.filter((l) => l.stormSource).map((l) => l.id));
const stormPipeline = sum(kanbanLeads.filter((l) => isPresale(l) && l.stormSource), (l) => l.value);
const totalPipeline = pipelineValue(kanbanLeads);
const stormProjects = projects.filter((p) => stormLeadIds.has(p.sourceLeadId));
const stormRevenue = revenueSummary(stormProjects).revenue;
const totalRevenue = revenueSummary(projects).revenue;
return {
stormLeadCount: stormLeadIds.size,
stormPipeline, totalPipeline,
pipelinePct: totalPipeline ? Math.round((stormPipeline / totalPipeline) * 100) : 0,
stormRevenue, totalRevenue,
revenuePct: totalRevenue ? Math.round((stormRevenue / totalRevenue) * 100) : 0,
};
}
```
- [ ] **Step 2 (unit-verify with fixtures):** write a throwaway `node` script (OS temp dir, NOT the repo) that imports the selectors and asserts on small hand-built fixture arrays — e.g. `pipelineFunnel` buckets correctly, `winRate({won:3,lost:1})===75`, `revenueSummary` gross/net arithmetic, `commissionByRep` groups + sorts, `stormAttribution` percentages. Paste the pass/fail output into your report. Delete the script.
- [ ] **Step 3 (reconcile against real data):** confirm the module imports cleanly (no syntax error): `npm run build``✓ built` (note: until a dashboard imports it, the bundler tree-shakes it; that's fine — also do `node -e "import('./src/data/selectors.js').then(m=>console.log(Object.keys(m)))"` to prove it loads and exports all 7 names).
- [ ] **Step 4:** commit `git commit -am "feat(analytics): add pure selectors for pipeline, revenue, commission, sub performance, storm"`.
## Task 4: Fix broken pre-sale lead route paths
**Files:** `src/App.jsx` only. **Independent — may run any time in Phase B.**
The three pre-sale lead-detail routes use backslashes and a missing slash, so React Router never matches them and clicking a pre-sale lead 404s.
- [ ] **Step 1:** Replace the three malformed `path` strings:
- `path="\emp\fa\leads:leadId"``path="/emp/fa/leads/:leadId"`
- `path="\owner\leads:leadId"``path="/owner/leads/:leadId"`
- `path="\admin\leads:leadId"``path="/admin/leads/:leadId"`
(Edit only the `path` attribute strings; leave the `<ProtectedRoute>`/element unchanged.)
- [ ] **Step 2 (verify):** `npm run build``✓ built`. `git grep -nE "path=\"\\\\" -- src/App.jsx` → no backslash route paths remain.
- [ ] **Step 3:** commit `git commit -am "fix(routes): repair backslash lead-detail route paths so pre-sale leads open"`.
## Task 5: Wire the owner dashboard to selectors
**Files:** `src/pages/owner/OwnerSnapshot.jsx` only. **Runs after T3 commits.**
- [ ] **Step 1:** Import the selectors and the arrays they need. The component already destructures `projects` from `useMockStore()`; also pull `kanbanLeads`, `subcontractorTasks` (or `MOCK_SUBCONTRACTOR_TASKS` exposure — use whatever the store exposes; check the context value), and `resolvePerson`.
```js
import { pipelineValue, pipelineFunnel, winRate, revenueSummary, commissionByRep, stormAttribution } from '../../data/selectors';
```
- [ ] **Step 2:** Compute KPIs in a `useMemo` from the live arrays (owner-scoped where appropriate — owner sees their own projects via the existing `ownerProjects`, but pipeline/funnel are company-wide pre-sale):
```js
const analytics = useMemo(() => ({
pipeline: pipelineValue(kanbanLeads),
funnel: pipelineFunnel(kanbanLeads),
win: winRate(kanbanLeads),
rev: revenueSummary(ownerProjects),
commissions: commissionByRep(ownerProjects, resolvePerson),
storm: stormAttribution(kanbanLeads, ownerProjects),
}), [kanbanLeads, ownerProjects, resolvePerson]);
```
- [ ] **Step 3:** Surface them in the UI following the existing card/`NeoCard` patterns on this page: add KPI tiles for **Open Pipeline** (`analytics.pipeline`), **Win Rate** (`analytics.win.rate`%), **Recognized Revenue** + **Gross/Net Profit** (`analytics.rev`); a **funnel** mini-bar from `analytics.funnel`; a **Commission by Rep** list (`analytics.commissions`); and a **Storm-attributed %** stat (`analytics.storm.revenuePct`). Reuse the page's existing chart components where one fits; keep new markup consistent with the current cards. Do **not** invent new numbers — every value comes from `analytics`.
- [ ] **Step 4 (verify):** `npm run build``✓ built`. Dev-run as an owner: the new KPIs render and are non-zero/coherent.
- [ ] **Step 5:** commit `git commit -am "feat(owner): wire OwnerSnapshot KPIs to computed selectors (pipeline, win-rate, revenue, commission, storm)"`.
## Task 6: Wire the admin dashboard to selectors
**Files:** `src/pages/Dashboard.jsx` only. **Runs after T3 commits; parallel-safe with T5/T4.**
- [ ] **Step 1:** Read `Dashboard.jsx` to find its current KPI/stat section and what it pulls from `useMockStore()`.
- [ ] **Step 2:** Import the same selectors. Compute a company-wide analytics `useMemo` over **all** `projects` + `kanbanLeads` (admin sees the whole company, not owner-scoped): `pipelineValue`, `pipelineFunnel`, `winRate`, `revenueSummary`, `stormAttribution`, and `commissionByRep` if a commission panel fits.
- [ ] **Step 3:** Render the company-wide pipeline/funnel/win-rate/revenue/storm metrics following the existing dashboard card style. Replace any hardcoded KPI value that a selector now computes; leave purely-illustrative trend sparklines as-is (Master §2 exception).
- [ ] **Step 4 (verify):** `npm run build``✓ built`. Dev-run as admin: KPIs render coherently.
- [ ] **Step 5:** commit `git commit -am "feat(admin): wire Dashboard KPIs to computed selectors"`.
## Task 7: Verify (read-only)
- [ ] `npm run build` clean.
- [ ] `git grep -cE "\(972\) 555-0[0-9]{3}" -- src/data/mockStore.jsx` near 0 (fake phones gone from pre-sale leads).
- [ ] `git grep -c "demoScenario:" -- src/data/mockStore.jsx` → 10; each scenario number 110 appears exactly once.
- [ ] `git grep -n "columnId: 'kc_lost'"` → the lost lead exists; win-rate selector counts it.
- [ ] **Reconciliation (the core acceptance):** dispatch a read-only reviewer (or run a throwaway `node` check over fixtures + a hand-sum over the real arrays) confirming: **owner/admin dashboard pipeline total == `pipelineValue(kanbanLeads)` == Σ pre-sale lead values**; **revenue == Σ paid `paymentSchedule` across the scoped projects**; **commission-by-rep totals == Σ `project.commission` grouped by rep**; **storm % == storm-sourced share**. Every on-screen KPI equals the sum of the records it summarizes (Master §14 "Dashboards reconcile").
- [ ] Dev-run: clicking a **pre-sale** lead opens `LeadProjectPage` (route fix works); clicking a **won** lead opens `/projects/PRJ-2026-###` (Plan 2). Funnel shows realistic attrition; storm attribution non-zero.
---
## Self-review checklist
- [ ] 28 pre-sale leads all have realistic name/address/phone/email/value/dates/activity; fake sequential phones gone.
- [ ] 10 archetypes tagged `demoScenario:1..10` (each once); lost lead `kl_045` added with `kc_lost`/`outcome:'lost'`.
- [ ] Any project edited for #6/#10 still satisfies the financial invariants.
- [ ] `selectors.js` exists with all 7 exports; pure (no React/mockStore import); fixture-verified.
- [ ] Owner + admin dashboards render computed KPIs; no hardcoded value a selector should own.
- [ ] App.jsx lead routes match `/…/leads/:leadId`; pre-sale leads clickable.
- [ ] Build clean; dashboard KPIs reconcile to record sums.
## Deferred to Phase 4 (do NOT build here)
- Damage/final **inspections**, **estimate version** history, scope-variance badges, and the formal **On Hold** lifecycle state machine. Plan 3 authored the present-tense data for scenarios #2/#5/#10; their interactive lifecycle mechanics land in Plan 4.
## Notes for Plan 4
Plan 4 consumes the `demoScenario` tags to drive its lifecycle/inspection/estimate UI against the exact records this plan curated — e.g. scenario #2 gets the damage-inspection → supplement → approved thread, #5 the estimate-version history, #10 the On Hold state machine and paused-payment timeline.