# 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_023–038`, 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_023–038`). **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 "), 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 e1–e5), 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 ~40–60% 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 ~85–95% 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: , 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((actualCost−budget)/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 ``/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 1–10 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.