24 KiB
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, nowPRJ-2026-013…028via Plan 2). The remaining 28 are pre-sale, distributed acrosskc_new/kc_contacted/kc_appt/kc_estimate(+ optionalbucketIdkc_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.jsxalready reduces overprojectsfortotalBudget/totalSpent/avgHealth/status counts, but computes no pipeline value, funnel, win-rate, revenue/profit, commission-by-rep, sub performance, or storm attribution; it readsowner.portfolioSize/owner.riskScoreas static fields. It does not look atkanbanLeads.Dashboard.jsx(admin/field) andLeaderboardPage.jsxexist; no shared analytics module.src/data/selectors.jsdoes not exist.App.jsxroute 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.jsxand excludekl_023–kl_038. Confirm the count (~28) and note each one's currentcolumnId. - Step 2 — kill the fake phones. Replace every sequential
(972) 555-0NNNphone 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.jsxdrops 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_newmay havevalue: nullor a rough number;kc_estimateshould have a firm value). - Step 4 — real dates + activity threads. Each lead's
createdDateis a real 2026 date consistent with its stage (newer leads later). Eachactivity[]is a coherent thread of{from, to, by, date, note}transitions ending at the lead's currentcolumnIdstage,by= the lead'sassignedAgentName(a canonical field agent e1–e5), dates strictly increasing and ≤DEMO_TODAY(2026-05-29). Follow the existing pre-sale lead style (see any currentkl_0xxwith anactivityarray). - Step 5 — storm attribution + stage coherence. Set
stormSource: trueon 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 missingname/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
projectsand 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'scommittedCost/change-order so committed exceeds budget while keepingΣbreakdown.committed == committedCostandcommittedCost ≥ actualCost; recomputevariancePercent. - #7 Large commercial, multi-phase → the largest commercial project (e.g.
PRJ-2026-007) with a phasedpaymentScheduleand ≥2 subs insubcontractorIds/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%, allpaymentSchedulepaid, all invoices paid. - #10 Disputed / on-hold → a low-health project with a dispute note in
activityTimeline/issueLogand paused payments (the formal On Hold state machine is Phase 4 — for now usehealthScore~30 + a dispute entry + apendingpaymentSchedule and anotes/issue describing the dispute).
- #1 Storm-driven win on track → an active storm-sourced project (e.g.
- 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 thekc_loststage 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 > budgetis 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:
// 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
nodescript (OS temp dir, NOT the repo) that imports the selectors and asserts on small hand-built fixture arrays — e.g.pipelineFunnelbuckets correctly,winRate({won:3,lost:1})===75,revenueSummarygross/net arithmetic,commissionByRepgroups + sorts,stormAttributionpercentages. 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 donode -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
pathstrings: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 thepathattribute 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
projectsfromuseMockStore(); also pullkanbanLeads,subcontractorTasks(orMOCK_SUBCONTRACTOR_TASKSexposure — use whatever the store exposes; check the context value), andresolvePerson.
import { pipelineValue, pipelineFunnel, winRate, revenueSummary, commissionByRep, stormAttribution } from '../../data/selectors';
- Step 2: Compute KPIs in a
useMemofrom the live arrays (owner-scoped where appropriate — owner sees their own projects via the existingownerProjects, but pipeline/funnel are company-wide pre-sale):
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/
NeoCardpatterns 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 fromanalytics.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 fromanalytics. - 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.jsxto find its current KPI/stat section and what it pulls fromuseMockStore(). - Step 2: Import the same selectors. Compute a company-wide analytics
useMemoover allprojects+kanbanLeads(admin sees the whole company, not owner-scoped):pipelineValue,pipelineFunnel,winRate,revenueSummary,stormAttribution, andcommissionByRepif 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 buildclean.git grep -cE "\(972\) 555-0[0-9]{3}" -- src/data/mockStore.jsxnear 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
nodecheck over fixtures + a hand-sum over the real arrays) confirming: owner/admin dashboard pipeline total ==pipelineValue(kanbanLeads)== Σ pre-sale lead values; revenue == Σ paidpaymentScheduleacross the scoped projects; commission-by-rep totals == Σproject.commissiongrouped 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 leadkl_045added withkc_lost/outcome:'lost'. - Any project edited for #6/#10 still satisfies the financial invariants.
selectors.jsexists 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.