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

23 KiB
Raw Blame History

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 lead routes — verified correct (no fix needed). An earlier review flagged these as backslash paths (path="\owner\leads:leadId"), but that was a Windows grep/terminal display artifact; the committed file already has correct forward-slash routes (/emp/fa/leads/:leadId, /owner/leads/:leadId, /admin/leads/:leadId), confirmed in both the working tree and HEAD. T4 below is therefore a no-op verification, not a code change.

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 Lead-route verify (no-op) src/App.jsx (read-only) 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_023kl_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 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:
// 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: Verify pre-sale lead routes (no-op)

Files: src/App.jsx (read-only). Independent.

The previously-suspected backslash route bug was a Windows grep display artifact; the routes are already correct. This task just confirms it — no code change, no commit.

  • Step 1 (verify): git grep -nE 'path="\\\\' -- src/App.jsx → no matches. git grep -nE 'path="[^"]*leads/:leadId"' -- src/App.jsx → shows /emp/fa/leads/:leadId, /owner/leads/:leadId, /admin/leads/:leadId.
  • Step 2 (dev-run, folded into T7): clicking a pre-sale lead opens LeadProjectPage.

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.
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):
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.