From 9bf00811f52eda190b4eddbdd6e62d7d3e6e5953 Mon Sep 17 00:00:00 2001 From: JP Date: Wed, 22 Apr 2026 15:31:37 -0400 Subject: [PATCH 01/11] feat(progress): split Pipeline Flow into Active funnel + Closed section (A5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context ------- Mega-B / A5. Bedrock 1.0 Progress page called for Lost / Withdrawn / Did Not Fulfill to be visible alongside the active funnel. JP also moved 'Collecting / In Effect' out of the active funnel into the new Closed section (def: "closed, contract in effect, payments in progress"). Changes ------- - pipelineFunnelTransitions.ts * ACTIVE_FUNNEL_STAGES now re-exports OPEN_STAGES from types/salesforce (7 stages, no Collecting). Single source of truth. * New CLOSED_FUNNEL_STAGES constant with 5 SF-canonical terminal stages in display order (Collecting / In Effect, Closed / Completed, Closed Lost, Withdrawn, Closed / Did not Fulfill). * classifyTransition gains an 'unclose' guard: FROM a known terminal (WON_STAGES/LOST_STAGES member) BACK TO an active stage is backward. Without this, the prior implicit behavior (Collecting in both active and WON_STAGES) masked that STAGE_IDX.get(Collecting) would become undefined post-split, so fi=-1 against an active ti>=0 would return 'forward' — wrong. Guard fires only when from IS a recognized terminal, so unknown-from (legacy pre-funnel stages) still treats as forward per the existing semantics. - PipelineFunnel.tsx * Parallel closedOpps/closedStageTotals memos built from the full opportunities prop scoped by the existing owner filter. * New Closed section renders below the active funnel — 5 horizontal cards (flex, equal-width, min 140px) showing colored stage dot, name, count, and total amount. Tooltips mirror the active-funnel tooltip shape for consistency. No transition-activity tracking on the Closed cards — they're a snapshot, not a flow. - PipelineFunnel.test.ts * 4 new 'A5 funnel split — terminal unclose regressions' tests pinning the new classifyTransition guard (Collecting → Proposal Negotiation, Closed / Completed → Qualifying, Closed Lost → Proposal Negotiation, Withdrawn → Contract Creation all classify as 'backward'). * 3 new 'A5 funnel split — exports' tests pinning the split: active has 7 and excludes Collecting; closed has 5 in display order; active+closed are disjoint. Honors feedback_sf_stages_sacred -------------------------------- Every stage string is verbatim from the SF picklist. No reclassification of existing stage semantics; WON_STAGES/LOST_STAGES in types/salesforce unchanged. Collecting / In Effect remains a WON_STAGES member (transitions into it still classify as 'won' per the existing test at line 26-29). Tests ----- - PipelineFunnel.test.ts → 28 passed (21 existing + 7 new). No regressions. - tsc --noEmit → clean. Verify ------ Open Progress page → funnel shows 7 active-stage bars (Lead Gen through Negotiating Contract), no Collecting bar. New Closed section below shows 5 cards with correct counts + total amounts. Sum of (active counts + closed counts) == total opps in the lookback window for the selected owner(s). Part of mega-B rollup targeting dev. Plan at ~/.claude/plans/mutable-doodling-brook.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/PipelineFunnel.test.ts | 62 +++++++++- .../src/components/PipelineFunnel.tsx | 107 ++++++++++++++++++ .../components/pipelineFunnelTransitions.ts | 34 ++++-- 3 files changed, 192 insertions(+), 11 deletions(-) diff --git a/financial_forecasting/frontend/src/components/PipelineFunnel.test.ts b/financial_forecasting/frontend/src/components/PipelineFunnel.test.ts index 7d8796bc..e2655d74 100644 --- a/financial_forecasting/frontend/src/components/PipelineFunnel.test.ts +++ b/financial_forecasting/frontend/src/components/PipelineFunnel.test.ts @@ -1,4 +1,10 @@ -import { classifyTransition, WON_STAGES, LOST_STAGES } from './pipelineFunnelTransitions'; +import { + ACTIVE_FUNNEL_STAGES, + CLOSED_FUNNEL_STAGES, + classifyTransition, + WON_STAGES, + LOST_STAGES, +} from './pipelineFunnelTransitions'; // Pipeline stage transition classifier — exercises the logic that was // previously misbucketing wins as setbacks because terminal stages aren't @@ -133,4 +139,58 @@ describe('classifyTransition', () => { expect(warnSpy).not.toHaveBeenCalled(); }); }); + + // A5 Progress funnel split (mega-B, 2026-04-22): Collecting / In Effect was + // removed from ACTIVE_FUNNEL_STAGES and lives in CLOSED_FUNNEL_STAGES. The + // classifier still treats INTO-Collecting as 'won' (WON_STAGES unchanged), + // and adds an "unclose" guard so FROM-terminal BACK TO active reads as + // 'backward' (would otherwise misclassify as 'forward' because terminal + // stages aren't in STAGE_IDX → fi=-1). + describe('A5 funnel split — terminal unclose regressions', () => { + it('classifies Collecting / In Effect → Proposal Negotiation as backward (post-split)', () => { + // Was backward when Collecting was in STAGE_IDX (fi=7, ti=4). After the + // split, fi=-1 and the new guard catches this as a known-terminal + // regression. + expect(classifyTransition('Collecting / In Effect', 'Proposal Negotiation')).toBe('backward'); + }); + + it('classifies Closed / Completed → Qualifying as backward (win regression)', () => { + expect(classifyTransition('Closed / Completed', 'Qualifying')).toBe('backward'); + }); + + it('classifies Closed Lost → Proposal Negotiation as backward (loss regression)', () => { + expect(classifyTransition('Closed Lost', 'Proposal Negotiation')).toBe('backward'); + }); + + it('classifies Withdrawn → Contract Creation as backward (loss regression)', () => { + expect(classifyTransition('Withdrawn', 'Contract Creation')).toBe('backward'); + }); + }); + + describe('A5 funnel split — exports', () => { + it('ACTIVE_FUNNEL_STAGES has 7 stages and excludes Collecting / In Effect', () => { + expect(ACTIVE_FUNNEL_STAGES.length).toBe(7); + expect(ACTIVE_FUNNEL_STAGES.includes('Collecting / In Effect' as any)).toBe(false); + // Sanity: the 7 open stages we expect + expect(ACTIVE_FUNNEL_STAGES.includes('Lead Gen' as any)).toBe(true); + expect(ACTIVE_FUNNEL_STAGES.includes('Negotiating Contract' as any)).toBe(true); + }); + + it('CLOSED_FUNNEL_STAGES has 5 SF-canonical terminal stages in display order', () => { + expect(CLOSED_FUNNEL_STAGES).toEqual([ + 'Collecting / In Effect', + 'Closed / Completed', + 'Closed Lost', + 'Withdrawn', + 'Closed / Did not Fulfill', + ]); + }); + + it('ACTIVE and CLOSED sets are disjoint (no stage in both)', () => { + const activeSet = new Set(ACTIVE_FUNNEL_STAGES); + for (const stage of CLOSED_FUNNEL_STAGES) { + expect(activeSet.has(stage)).toBe(false); + } + }); + }); }); diff --git a/financial_forecasting/frontend/src/components/PipelineFunnel.tsx b/financial_forecasting/frontend/src/components/PipelineFunnel.tsx index b8d3d1fa..efec53fe 100644 --- a/financial_forecasting/frontend/src/components/PipelineFunnel.tsx +++ b/financial_forecasting/frontend/src/components/PipelineFunnel.tsx @@ -40,6 +40,7 @@ import LookbackRangeSelector, { import { ACTIVE_FUNNEL_STAGES, + CLOSED_FUNNEL_STAGES, STAGE_IDX, WON_STAGES, LOST_STAGES, @@ -332,6 +333,31 @@ const PipelineFunnel: React.FC = ({ opportunities, selected [opportunities, effectiveOwnerIds], ); + // Closed-stage opps — parallel to filteredOpps, same owner filter, but + // scoped to the terminal stages we render below the active funnel. + const closedOpps = useMemo( + () => + opportunities.filter( + (opp) => + CLOSED_FUNNEL_STAGES.includes(opp.StageName as any) && + (!effectiveOwnerIds || (opp.OwnerId && effectiveOwnerIds.has(opp.OwnerId))), + ), + [opportunities, effectiveOwnerIds], + ); + + const closedStageTotals = useMemo(() => { + const totals = new Map(); + for (const stage of CLOSED_FUNNEL_STAGES) totals.set(stage, { count: 0, total: 0 }); + for (const opp of closedOpps) { + const entry = totals.get(opp.StageName); + if (entry) { + entry.count++; + entry.total += opp.Amount || 0; + } + } + return totals; + }, [closedOpps]); + const funnel = useMemo(() => buildFunnelData(filteredOpps, history), [filteredOpps, history]); const maxCount = Math.max(...funnel.map((l) => l.count), 1); @@ -707,6 +733,87 @@ const PipelineFunnel: React.FC = ({ opportunities, selected })} + {/* Closed section — terminal stages (wins + losses) rendered as compact + horizontal cards below the active funnel. These opps have exited + the active pipeline but are relevant to Progress-page reporting: + Collecting / In Effect (signed, payment in progress) through + Closed / Completed, and the three loss outcomes. */} + + + Closed + + + {CLOSED_FUNNEL_STAGES.map((stage) => { + const entry = closedStageTotals.get(stage) || { count: 0, total: 0 }; + const color = getStageHexColor(stage); + return ( + + + + + {stage} + + + + + + {entry.count} opp{entry.count !== 1 ? 's' : ''} + + + + + {formatDollarMillions(entry.total)} + + + + + ); + })} + + + {!analysisData && !analysisLoading && (