diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index 1f6303d..714a902 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -35,11 +35,27 @@ test('counts merged-in-window PRs and surfaces backlog age buckets', () => { mergedInWindowCount: 1, oldestOpenDays: expect.any(Number) as number, }); - // The 263-day-old PR should fall into the 180+ bucket. - const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days'); + // The 263-day-old PR should fall into the 90+ bucket (90 and 180+ are no longer split). + const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '90+ days'); expect(oldBucket?.count).toBe(1); }); +test('averages open PR age and reports null when nothing is open', () => { + // collectionContext.now is 2026-05-22, so these open PRs are 10 and 30 days old. + const withOpen = collectedData.build({ + dependabotPrs: [ + dependabotPr.build({ state: 'open', createdAt: '2026-05-12T00:00:00Z' }), + dependabotPr.build({ state: 'open', createdAt: '2026-04-22T00:00:00Z' }), + ], + }); + expect(aggregate(withOpen).prBacklog.openAvgAgeDays).toBe(20); + + const noOpen = collectedData.build({ + dependabotPrs: [dependabotPr.build({ state: 'closed', merged: true, mergedAt: '2026-04-01T00:00:00Z' })], + }); + expect(aggregate(noOpen).prBacklog.openAvgAgeDays).toBeNull(); +}); + test('rolls org/visibility/language counts up into orgOverview', () => { const data = collectedData.build({ repos: [ @@ -180,13 +196,13 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges const bundle = aggregate(data); expect(bundle.costEstimate.humanMergeCount).toBe(100); expect(bundle.costEstimate.humanReviewCount).toBe(0); - expect(bundle.costEstimate.hourlyRateUsd).toBe(150); - expect(bundle.costEstimate.minutesPerPr).toBe(5); - // 100 actions × 5 min × $150/hr / 60 = $1250 in window - expect(bundle.costEstimate.windowCostUsd).toBe(1250); - // ~$423/month over 90 days (window × 30.44/90) - expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(400); - expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(450); + expect(bundle.costEstimate.hourlyRateUsd).toBe(200); + expect(bundle.costEstimate.minutesPerPr).toBe(12); + // 100 actions × 12 min × $200/hr / 60 = $4000 in window + expect(bundle.costEstimate.windowCostUsd).toBe(4000); + // ~$1,352/month over 90 days (window × 30.44/90) + expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1300); + expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1400); expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12); expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]); expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe( diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index 1c4364c..600f299 100644 --- a/src/report/aggregate.ts +++ b/src/report/aggregate.ts @@ -60,6 +60,7 @@ export interface PrBacklog { mergedInWindowCount: number; openAgeBuckets: Array<{ label: string; count: number }>; oldestOpenDays: number | null; + openAvgAgeDays: number | null; bumpTypeSplit: Array<{ bumpType: string; count: number; percentage: number }>; devOnlyShare: { count: number; percentage: number }; ciStatusMix: { green: number; failing: number; pending: number }; @@ -235,19 +236,17 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant) { label: '0–30 days', min: 0, max: 30 }, { label: '30–60 days', min: 30, max: 60 }, { label: '60–90 days', min: 60, max: 90 }, - { label: '90–180 days', min: 90, max: 180 }, - { label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY }, + { label: '90+ days', min: 90, max: Number.POSITIVE_INFINITY }, ]; + const openAges = openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt))); const openAgeBuckets = buckets.map((b) => ({ label: b.label, - count: openPrs.filter((p) => { - const age = daysBetween(now, instantFromString(p.createdAt)); - return age >= b.min && age < b.max; - }).length, + count: openAges.filter((age) => age >= b.min && age < b.max).length, })); - const oldestOpenDays = - openPrs.length === 0 ? null : Math.max(...openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt)))); + const oldestOpenDays = openAges.length === 0 ? null : Math.max(...openAges); + const openAvgAgeDays = + openAges.length === 0 ? null : Math.round(openAges.reduce((sum, age) => sum + age, 0) / openAges.length); const bumpCounts = new Map(); for (const pr of prs) { @@ -300,6 +299,7 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant) mergedInWindowCount: mergedInWindow.length, openAgeBuckets, oldestOpenDays, + openAvgAgeDays, bumpTypeSplit, devOnlyShare, ciStatusMix: { green, failing, pending }, diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts index b9d4530..f2590a6 100644 --- a/src/report/costFormulas.ts +++ b/src/report/costFormulas.ts @@ -2,8 +2,8 @@ // React app. No Temporal, no Bun globals, no React — keep this module dependency-free // so it bundles cleanly into both targets. -export const ASSUMED_HOURLY_RATE_USD = 150; -export const ASSUMED_MIN_PER_PR = 5; +export const ASSUMED_HOURLY_RATE_USD = 200; +export const ASSUMED_MIN_PER_PR = 12; export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const; diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index 5394c07..c4b4a25 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -11,6 +11,7 @@ import type { ReportMeta, StalledSignals, } from './aggregate.ts'; +import { ASSUMED_HOURLY_RATE_USD, ASSUMED_MIN_PER_PR, deriveCostEstimate, derivePersonCosts } from './costFormulas.ts'; import { type EmbeddedReportData, toEmbeddedShape } from './embeddedShape.ts'; import type { ReportAnalyticsConfig } from './reportAnalyticsConfig.ts'; @@ -56,10 +57,10 @@ export const prBacklog = Factory.define(() => ({ { label: '0–30 days', count: 40 }, { label: '30–60 days', count: 18 }, { label: '60–90 days', count: 6 }, - { label: '90–180 days', count: 25 }, - { label: '180+ days', count: 13 }, + { label: '90+ days', count: 38 }, ], oldestOpenDays: 312, + openAvgAgeDays: 74, bumpTypeSplit: [ { bumpType: 'patch', count: 150, percentage: 55 }, { bumpType: 'minor', count: 95, percentage: 34.8 }, @@ -77,31 +78,42 @@ export const stalledSignals = Factory.define(() => ({ reposWithConfigButNoRecentPrs: ['acme/old-tool'], })); +// Cost figures derive from the real defaults and formulas so the fixtures track +// production whenever the assumptions move, rather than restating stale literals. +const COST_WINDOW_DAYS = 90; +const HUMAN_MERGE_COUNT = 150; +const HUMAN_REVIEW_COUNT = 12; + export const people = Factory.define(() => ({ - mergers: [ - { login: 'alice', count: 90, windowCostUsd: 1125, annualCostUsd: 4563 }, - { login: 'bob', count: 60, windowCostUsd: 750, annualCostUsd: 3042 }, - ], - reviewers: [{ login: 'alice', count: 12, windowCostUsd: 90, annualCostUsd: 365 }], + mergers: derivePersonCosts( + [ + { login: 'alice', count: 90 }, + { login: 'bob', count: 60 }, + ], + COST_WINDOW_DAYS, + ASSUMED_MIN_PER_PR, + ASSUMED_HOURLY_RATE_USD, + ), + reviewers: derivePersonCosts( + [{ login: 'alice', count: 12 }], + COST_WINDOW_DAYS, + ASSUMED_MIN_PER_PR, + ASSUMED_HOURLY_RATE_USD, + ), commenters: [], })); export const costEstimate = Factory.define(() => ({ - humanMergeCount: 150, - humanReviewCount: 12, + humanMergeCount: HUMAN_MERGE_COUNT, + humanReviewCount: HUMAN_REVIEW_COUNT, openCount: 102, - windowDays: 90, - hourlyRateUsd: 150, - minutesPerPr: 5, - windowCostUsd: 2025, - monthlyCostUsd: 684, - annualCostUsd: 8208, - savingsScenarios: [ - { autoMergeRate: 0.5, monthlySavingsUsd: 342, annualSavingsUsd: 4104 }, - { autoMergeRate: 0.6, monthlySavingsUsd: 410, annualSavingsUsd: 4920 }, - { autoMergeRate: 0.7, monthlySavingsUsd: 479, annualSavingsUsd: 5748 }, - { autoMergeRate: 0.8, monthlySavingsUsd: 547, annualSavingsUsd: 6564 }, - ], + windowDays: COST_WINDOW_DAYS, + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + ...deriveCostEstimate(HUMAN_MERGE_COUNT + HUMAN_REVIEW_COUNT, COST_WINDOW_DAYS, { + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + }), })); export const cveExposureOk = Factory.define(() => ({ diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index 0c09d8a..ce10e7d 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -12,7 +12,6 @@ import { verdictCopy, verdictTestIds } from './acts/Verdict.tsx'; import { AnalyticsProvider } from './analytics/AnalyticsContext.tsx'; import { App, appTestIds } from './App.tsx'; import { assumptionInputTestIds } from './primitives/AssumptionInput.tsx'; -import { assumptionsFootnoteTestId } from './primitives/AssumptionsFootnote.tsx'; import { footnoteReferenceTestId } from './primitives/FootnoteReference.tsx'; import type { EmbeddedReportData } from './types.ts'; @@ -24,9 +23,11 @@ describe('App report shell', () => { it('renders the headline annual cost from the embedded data', () => { renderReport(); - expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$8,208/year'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$26,280/year'); expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costLeadIn); expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costTrailer); + // The headline clarifies it excludes the open backlog, which lives in its own section. + expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('not including the 102 still open'); }); it('recalculates the headline cost and comparison cards when assumptions change', () => { @@ -37,11 +38,11 @@ describe('App report shell', () => { target: { value: '300' }, }); - expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$16,428/year'); - expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$16,428/yr'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$39,420/year'); + expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$39,420/yr'); // "Today" mirrors the headline; "PatchWave savings" is the recovered cost at the default 65% share. - expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$16,428/yr'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$10,678/yr'); + expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$39,420/yr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$25,623/yr'); }); it('allows replacing an assumption value by clearing and typing', () => { @@ -57,7 +58,7 @@ describe('App report shell', () => { fireEvent.blur(hourlyRateInput); expect(hourlyRateInput).toHaveValue('275'); - expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$15,060/year'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$36,132/year'); }); it('recalculates the PatchWave savings card when the auto-merge share changes', () => { @@ -65,41 +66,12 @@ describe('App report shell', () => { // Default 65% share starts in the middle of the modeled range. expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$5,335/yr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,082/yr'); fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } }); expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$4,104/yr'); - }); - - it('reveals the methodology assumptions panel when an estimate footnote is clicked', () => { - renderReport(); - const details = screen.getByTestId(assumptionInputTestIds.container).closest('details'); - expect(details).toBeTruthy(); - expect(details).not.toHaveAttribute('open'); - - const footnote = screen.getAllByTestId(assumptionsFootnoteTestId)[0]; - if (!footnote) throw new Error('missing assumptions footnote'); - const restore = suppressNavigation(); - fireEvent.click(footnote); - restore(); - - expect(details).toHaveAttribute('open'); - }); - - it('switches back to the calculation tab when an assumptions footnote is clicked from raw data', () => { - renderReport(); - fireEvent.click(screen.getByText('How this report was calculated')); - fireEvent.click(screen.getByRole('tab', { name: 'Raw data' })); - expect(screen.queryByTestId(assumptionInputTestIds.container)).not.toBeInTheDocument(); - - const restore = suppressNavigation(); - fireEvent.click(screen.getAllByTestId(assumptionsFootnoteTestId)[0] as HTMLElement); - restore(); - - expect(screen.getByRole('tab', { name: 'Calculation' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument(); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$13,140/yr'); }); it('lists footnotes in ascending first-appearance order', () => { @@ -108,17 +80,17 @@ describe('App report shell', () => { fireEvent.click(screen.getByText('How this report was calculated')); const sources = screen.getByTestId(methodologyAppendixTestIds.sources); - expect(sources).toHaveTextContent('1. Adjustable cost assumptions.'); + // The solution section leads the report, so its Mohayeji citation is the first footnote. + expect(sources).toHaveTextContent('1. Mohayeji et al. 2025'); expect(sources).toHaveTextContent('2. VulnCheck, May 2026'); expect(sources).toHaveTextContent('3. Anthropic, "Project Glasswing'); expect(sources).toHaveTextContent('4. Anthropic, Coordinated Vulnerability Disclosure dashboard'); - expect(sources).toHaveTextContent('5. Mohayeji et al. 2025'); - expect(sources).toHaveTextContent('6. Atlassian State of Developer Experience Report 2025.'); + expect(sources).toHaveTextContent('5. Atlassian State of Developer Experience Report 2025.'); }); it('opens the appendix source note instead of navigating directly when a citation is clicked', () => { renderReport(); - const details = screen.getByTestId(assumptionInputTestIds.container).closest('details'); + const details = screen.getByTestId(methodologyAppendixTestIds.section).querySelector('details'); expect(details).toBeTruthy(); expect(details).not.toHaveAttribute('open'); @@ -136,7 +108,6 @@ describe('App report shell', () => { it('renders the ok CVE state with severity counts', () => { renderReport(); - expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent('7 open security alerts'); expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent('7 open security alerts'); expect(screen.getByTestId(riskStoryTestIds.severityBar)).toBeInTheDocument(); }); @@ -196,7 +167,6 @@ describe('App report shell', () => { }, }); - expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent(verdictCopy.cveScopeMissing); expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent(riskStoryCopy.scopeMissingHeading); expect(screen.getByTestId(riskStoryTestIds.scopeRefreshCommand)).toHaveTextContent( 'gh auth refresh -s security_events', @@ -213,8 +183,6 @@ describe('App report shell', () => { expect(screen.getByTestId(openPrAgeStoryTestIds.section)).toHaveTextContent(openPrAgeStoryCopy.heading); expect(screen.getByTestId(riskStoryTestIds.section)).toHaveTextContent(riskStoryCopy.eyebrow); expect(screen.getByTestId(callToActionTestIds.section)).toHaveTextContent(callToActionCopy.heading); - expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('based on adjustable1 assumptions'); - expect(screen.getByTestId(automatedStoryTestIds.todayCost).parentElement).toHaveTextContent('Adjustable1 estimate'); expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta); expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default'); expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel); @@ -230,26 +198,19 @@ describe('App report shell', () => { expect(screen.getByTestId(methodologyAppendixTestIds.section)).not.toHaveTextContent('patchwave.ai'); }); - it('places every assumptions footnote immediately after adjustable', () => { - renderReport(); - fireEvent.click(screen.getByText('How this report was calculated')); - - for (const footnote of screen.getAllByTestId(assumptionsFootnoteTestId)) { - const previousText = footnote.previousSibling?.textContent ?? ''; - expect(previousText.trimEnd().toLowerCase().endsWith('adjustable')).toBe(true); - } - }); - it('combines person merge and review rows and labels the cost window', () => { renderReport(); const table = screen.getByTestId(costStoryTestIds.peopleTable); expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument(); - expect(within(table).getAllByText('alice')).toHaveLength(1); - expect(within(table).getByText('90')).toBeInTheDocument(); - expect(within(table).getAllByText('merged').length).toBeGreaterThan(0); - expect(within(table).getByText('12')).toBeInTheDocument(); - expect(within(table).getByText('reviewed')).toBeInTheDocument(); + const aliceCells = within(table).getAllByText('alice'); + expect(aliceCells).toHaveLength(1); + // alice merged and reviewed, so her two activity rows collapse into one combined row. + const aliceRow = aliceCells[0]?.closest('tr'); + expect(aliceRow).toHaveTextContent('90'); + expect(aliceRow).toHaveTextContent('merged'); + expect(aliceRow).toHaveTextContent('12'); + expect(aliceRow).toHaveTextContent('reviewed'); }); it('limits the people table to the top five with an optional expansion', () => { @@ -285,16 +246,17 @@ describe('App report shell', () => { }); const table = screen.getByTestId(costStoryTestIds.peopleTable); - // 10 reviews x 5 min x $150/hr / 60 = $125 in window at the defaults. - expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$125'); + // 10 reviews x 12 min x $200/hr / 60 = $400 in window at the defaults. + expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$400'); const assumptions = screen.getByTestId(assumptionInputTestIds.container); fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), { target: { value: '10' }, }); - // Reviews ride the same minutes-per-PR slider as merges, so doubling it doubles the cost. - expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$250'); + // Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the cost: + // 10 reviews x 10 min x $200/hr / 60 = $333. + expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$333'); }); it('reworks the raw-data per-person costs when an assumption changes', () => { @@ -306,7 +268,7 @@ describe('App report shell', () => { }, }); - // The input lives on the Calculation tab and unmounts on Raw data, so adjust before navigating. + // The assumptions control lives in the hero, so it stays editable regardless of the appendix tab. const assumptions = screen.getByTestId(assumptionInputTestIds.container); fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), { target: { value: '10' }, @@ -315,9 +277,9 @@ describe('App report shell', () => { fireEvent.click(screen.getByText('How this report was calculated')); fireEvent.click(screen.getByRole('tab', { name: 'Raw data' })); - // 10 reviews x 10 min x $150/hr / 60 = $250 window, annualized to $1,014/yr. + // 10 reviews x 10 min x $200/hr / 60 = $333 window, annualized to $1,351/yr. const rawData = screen.getByTestId(methodologyAppendixTestIds.rawData); - expect(within(rawData).getByText('carol').closest('li')).toHaveTextContent('$1,014/yr'); + expect(within(rawData).getByText('carol').closest('li')).toHaveTextContent('$1,351/yr'); }); it('renders the open PR age buckets as a separate section with count-only rows', () => { @@ -326,6 +288,11 @@ describe('App report shell', () => { const section = screen.getByTestId(openPrAgeStoryTestIds.section); const breakdown = screen.getByTestId(openPrAgeStoryTestIds.breakdown); expect(section).toHaveTextContent(openPrAgeStoryCopy.heading); + // Headline backlog stats summarize the section before the per-bucket bars. + expect(section).toHaveTextContent('102'); + expect(section).toHaveTextContent('still open'); + expect(section).toHaveTextContent('74 days'); + expect(section).toHaveTextContent('average age'); expect(breakdown).toHaveTextContent('0–30 days'); expect(breakdown).toHaveTextContent('40'); expect(breakdown).toHaveTextContent('Time-to-merge in your data: p50 2d, p90 14d'); @@ -339,6 +306,7 @@ describe('App report shell', () => { ...embeddedReportData.build().prBacklog, openCount: 0, oldestOpenDays: null, + openAvgAgeDays: null, openAgeBuckets: [], }, }); @@ -347,6 +315,7 @@ describe('App report shell', () => { expect(section).toHaveTextContent(openPrAgeStoryCopy.emptyHeading); expect(section).not.toHaveTextContent(openPrAgeStoryCopy.heading); expect(section).not.toHaveTextContent('Volume is trending up, not down'); + expect(section).not.toHaveTextContent('average age'); }); }); diff --git a/src/report/web/App.stories.tsx b/src/report/web/App.stories.tsx index 19b0d83..277b1a8 100644 --- a/src/report/web/App.stories.tsx +++ b/src/report/web/App.stories.tsx @@ -7,6 +7,27 @@ import { App } from './App.tsx'; // Defaults cover most of the page; the overrides here only fill the spots that // would otherwise render as single-row tables (language mix, top repos by // severity) so the snapshot exercises the full UI. +// +// The global severity totals are summed from the per-repo rows so the +// distribution bar and the per-repo breakdown always reconcile, mirroring how +// production derives both from the same alert list. +const topReposBySeverity = [ + { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 }, + { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 }, + { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 }, + { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 }, + { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 }, + { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 }, + { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 }, + { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 }, +]; +const bySeverity = { + critical: sumBy(topReposBySeverity, 'critical'), + high: sumBy(topReposBySeverity, 'high'), + medium: sumBy(topReposBySeverity, 'medium'), + low: sumBy(topReposBySeverity, 'low'), +}; + const sampleReport = toEmbeddedShape( reportBundle.build({ orgOverview: orgOverview.build({ @@ -18,16 +39,9 @@ const sampleReport = toEmbeddedShape( ], }), cve: cveExposureOk.build({ - topReposBySeverity: [ - { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 }, - { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 }, - { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 }, - { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 }, - { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 }, - { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 }, - { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 }, - { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 }, - ], + totalOpenAlerts: bySeverity.critical + bySeverity.high + bySeverity.medium + bySeverity.low, + bySeverity, + topReposBySeverity, reposWithSecurityAlertsDisabled: ['acme/legacy-cron'], }), people: people.build({ @@ -86,3 +100,7 @@ export const CveScopeMissing: Story = { data: { ...sampleReport, cve: cveExposureScopeMissing.build() }, }, }; + +function sumBy(rows: readonly T[], key: keyof T): number { + return rows.reduce((total, row) => total + (row[key] as number), 0); +} diff --git a/src/report/web/App.tsx b/src/report/web/App.tsx index c418b56..7b3b55e 100644 --- a/src/report/web/App.tsx +++ b/src/report/web/App.tsx @@ -31,10 +31,10 @@ export function App({ data }: { data: EmbeddedReportData }) {
+ -
diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx index ea967e5..bf8fd2f 100644 --- a/src/report/web/acts/AutomatedStory.tsx +++ b/src/report/web/acts/AutomatedStory.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; +import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; +import { Button } from '../components/ui/button.tsx'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; -import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; import { Citation } from '../primitives/Citation.tsx'; +import { callToActionCopy } from './CallToAction.tsx'; export const automatedStoryTestIds = { section: 'automated-story-section', @@ -10,14 +12,22 @@ export const automatedStoryTestIds = { patchwaveCost: 'automated-story-patchwave-cost', delta: 'automated-story-delta', shareSlider: 'automated-story-share-slider', + waitlistCta: 'automated-story-waitlist-cta', } as const; const SHARE_MIN = 50; const SHARE_MAX = 80; +const SHARE_STEP = 5; const SHARE_DEFAULT = 65; +const SHARE_MID = (SHARE_MIN + SHARE_MAX) / 2; +const SHARE_STOPS = Array.from( + { length: (SHARE_MAX - SHARE_MIN) / SHARE_STEP + 1 }, + (_, i) => SHARE_MIN + i * SHARE_STEP, +); export function AutomatedStory() { const { assumptions, derived } = useAssumptions(); + const analytics = useAnalytics(); const [sharePct, setSharePct] = useState(SHARE_DEFAULT); const todayCost = derived.annualCostUsd; @@ -43,7 +53,6 @@ export function AutomatedStory() { label="Today" value={`${fmtUsd(todayCost)}/yr`} sub={quarterHoursLabel(todayCost, assumptions.hourlyRateUsd)} - footnote />
- cost recovered + PRs auto-merged
setSharePct(Number(e.target.value))} - className="accent-primary mt-3 w-full" + className="accent-primary mt-3 block w-full" /> +
+ {SHARE_STOPS.map((stop) => ( + + ))} +
{SHARE_MIN}% + {SHARE_MID}% {SHARE_MAX}%
@@ -92,6 +110,16 @@ export function AutomatedStory() { wrong .

+ + ); } @@ -102,34 +130,23 @@ function CompareCard({ sub, testId, accent = false, - footnote = false, }: { label: string; value: string; sub: string; testId: string; accent?: boolean; - footnote?: boolean; }) { return (
{label}
{value}
-
- {footnote && ( - <> - Adjustable - estimate - · - - )} - {sub} -
+
{sub}
); } diff --git a/src/report/web/acts/CallToAction.tsx b/src/report/web/acts/CallToAction.tsx index adc2f82..372dd64 100644 --- a/src/report/web/acts/CallToAction.tsx +++ b/src/report/web/acts/CallToAction.tsx @@ -11,7 +11,7 @@ export const callToActionCopy = { pitch: 'PatchWave reviews each Dependabot PR, auto-merges the safe updates, and gives engineers context for the few that need judgment.', earlyAccess: "Early access, plus a heads-up when the public beta opens. That's all we'll email you about.", - ctaLabel: 'Join the waitlist →', + ctaLabel: 'Join the waitlist', } as const; export function CallToAction() { diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx index 9826c01..0acc250 100644 --- a/src/report/web/acts/CostStory.tsx +++ b/src/report/web/acts/CostStory.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; -import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; import { PersonRow } from '../primitives/PersonRow.tsx'; export const costStoryTestIds = { @@ -39,8 +38,7 @@ export function CostStory() {

In the last {data.meta.windowDays} days, your team merged{' '} {humanMergeCount.toLocaleString()} Dependabot PRs by hand. - Anything a bot auto-merged is left out. At adjustable - defaults of{' '} + Anything a bot auto-merged is left out. At{' '} {assumptions.minutesPerPr} minutes per PR and{' '} ${assumptions.hourlyRateUsd}/hr, that comes out to:

@@ -67,7 +65,8 @@ export function CostStory() {

- The 5 min/PR default is deliberately low, so these totals lean conservative. + The 12 min/PR default covers the context switch, review, and merge for a single PR. Anything a bot merged is + left out, so these totals only count human effort.

); @@ -95,7 +94,7 @@ function CostCell({ } function PeopleTable({ windowDays }: { windowDays: number }) { - const { derived } = useAssumptions(); + const { assumptions, derived } = useAssumptions(); const [expanded, setExpanded] = useState(false); const people = combinedPeopleRows(derived.mergers, derived.reviewers); const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT); @@ -120,6 +119,7 @@ function PeopleTable({ windowDays }: { windowDays: number }) { Person Count + Time (hrs) Cost over last {windowDays} days Annualized @@ -131,13 +131,14 @@ function PeopleTable({ windowDays }: { windowDays: number }) { login={r.login} mergedCount={r.mergedCount} reviewedCount={r.reviewedCount} + windowHours={Math.round(r.windowCostUsd / assumptions.hourlyRateUsd)} windowCostUsd={r.windowCostUsd} annualCostUsd={r.annualCostUsd} /> ))} {hiddenCount > 0 || expanded ? ( - + + ) : null} + + ); +} + +function RepoSeverityBar({ repo, maxTotal }: { repo: RepoSeverityRow; maxTotal: number }) { + const total = repoTotal(repo); + const counts = { critical: repo.critical, high: repo.high, medium: repo.medium, low: repo.low }; + return ( +
+
+ {repo.repo} +
+
+
+ {SEGMENTS.map((s) => { + const v = counts[s.key]; + if (v === 0) return null; + return ( +
+ ); + })} +
+
+
{total.toLocaleString()}
); } +function repoTotal(r: RepoSeverityRow): number { + return r.critical + r.high + r.medium + r.low; +} + function AgeCell({ label, days, tone }: { label: string; days: number; tone: 'critical' | 'high' }) { - const color = tone === 'critical' ? 'text-destructive' : 'text-tangerine'; + const color = tone === 'critical' ? 'var(--severity-critical)' : 'var(--severity-high)'; return (
{label}
-
{days} days
+
+ {days} days +
); } diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 169ca83..923f20a 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -3,12 +3,11 @@ import { Button } from '../components/ui/button.tsx'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; -import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; +import { HeroAssumptions } from '../primitives/HeroAssumptions.tsx'; export const verdictTestIds = { section: 'verdict-section', annualCost: 'verdict-annual-cost', - cveLine: 'verdict-cve-line', primaryCta: 'verdict-primary-cta', } as const; @@ -16,11 +15,11 @@ export const verdictCopy = { costLeadIn: 'Your engineering team spends', costTrailer: 'triaging, reviewing, and merging Dependabot PRs', primaryCta: 'See how PatchWave helps', - cveScopeMissing: 'CVE exposure not measured (missing GitHub scope)', } as const; export function Verdict() { const { derived } = useAssumptions(); + const { openCount } = useEmbeddedData().prBacklog; const analytics = useAnalytics(); return ( @@ -34,12 +33,12 @@ export function Verdict() { /year

- {verdictCopy.costTrailer}, based on adjustable - assumptions + {verdictCopy.costTrailer} + {openCount > 0 && ( + (not including the {openCount.toLocaleString()} still open) + )}

- - - - ); -} -// One dense line under the headline: volume and time first, the alert count as a -// tail signal. The CVE fragment also carries the not-measured state so the -// report never silently drops the security signal. -function SupportingFacts() { - const { prBacklog: pr } = useEmbeddedData(); - const oldestSuffix = pr.oldestOpenDays !== null ? ` (oldest ${pr.oldestOpenDays} days)` : ''; - - return ( -

- - {pr.mergedInWindowCount.toLocaleString()} Dependabot PRs - merged - - - - {pr.openCount.toLocaleString()} still open{oldestSuffix} - - - -

- ); -} - -function CveFact() { - const cve = useEmbeddedData().cve; - if (cve.status === 'scope-missing') { - return ( - - {verdictCopy.cveScopeMissing} - - ); - } - return ( - - {cve.totalOpenAlerts.toLocaleString()} open security alerts - - ); -} - -function Dot() { - return ( - - · - +
+ +
+ ); } diff --git a/src/report/web/hooks/useAssumptionsDisclosure.tsx b/src/report/web/hooks/useAssumptionsDisclosure.tsx index 9bb3e40..64017ff 100644 --- a/src/report/web/hooks/useAssumptionsDisclosure.tsx +++ b/src/report/web/hooks/useAssumptionsDisclosure.tsx @@ -28,9 +28,6 @@ export function AssumptionsDisclosureProvider({ children }: { children: ReactNod const revealHashTarget = () => { if (window.location.hash.length <= 1) return; const target = decodeURIComponent(window.location.hash.slice(1)); - if (target === 'appendix-assumptions') { - reveal('calculation'); - } if (target === 'appendix-sources' || target.startsWith('footnote-')) { setOpen(true); } diff --git a/src/report/web/primitives/AssumptionsFootnote.tsx b/src/report/web/primitives/AssumptionsFootnote.tsx deleted file mode 100644 index 7576e5a..0000000 --- a/src/report/web/primitives/AssumptionsFootnote.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; -import { FootnoteReference } from './FootnoteReference.tsx'; - -export const assumptionsFootnoteTestId = 'assumptions-footnote'; -export const assumptionsFootnoteId = 'assumptions'; -export const assumptionsPanelId = 'appendix-assumptions'; - -export function AssumptionsFootnote({ from, className }: { from: string; className?: string }) { - const analytics = useAnalytics(); - return ( - analytics.capture('assumptions_revealed', { from })} - /> - ); -} diff --git a/src/report/web/primitives/FootnoteReference.tsx b/src/report/web/primitives/FootnoteReference.tsx index 33b4aef..311a3b5 100644 --- a/src/report/web/primitives/FootnoteReference.tsx +++ b/src/report/web/primitives/FootnoteReference.tsx @@ -7,7 +7,6 @@ import { footnoteMarkerClass } from './FootnoteMarker.tsx'; export const footnoteReferenceTestId = 'footnote-reference'; interface Props extends FootnoteRegistration { - kind?: 'note' | 'assumptions'; className?: string; testId?: string; titleText?: string; @@ -18,7 +17,6 @@ export function FootnoteReference({ id, title, body, - kind = 'note', className, testId = footnoteReferenceTestId, titleText, @@ -26,11 +24,10 @@ export function FootnoteReference({ }: Props) { const number = useFootnote({ id, title, body }); const { reveal } = useAssumptionsDisclosure(); - const href = kind === 'assumptions' ? '#appendix-assumptions' : '#appendix-sources'; return (
{ diff --git a/src/report/web/primitives/HeroAssumptions.tsx b/src/report/web/primitives/HeroAssumptions.tsx new file mode 100644 index 0000000..a7dee72 --- /dev/null +++ b/src/report/web/primitives/HeroAssumptions.tsx @@ -0,0 +1,32 @@ +import { useAssumptions } from '../hooks/useAssumptions.tsx'; +import { AssumptionInput } from './AssumptionInput.tsx'; + +export const heroAssumptionsTestIds = { + summary: 'hero-assumptions-summary', +} as const; + +export function HeroAssumptions() { + const { assumptions } = useAssumptions(); + return ( +
+ + + Assumes the loaded cost of engineering is{' '} + ${assumptions.hourlyRateUsd}/hr and it + takes {assumptions.minutesPerPr} minutes{' '} + to review each PR + + + Adjust + Done + + +
+ +
+
+ ); +} diff --git a/src/report/web/primitives/PersonRow.tsx b/src/report/web/primitives/PersonRow.tsx index 9699e4e..27f7eb1 100644 --- a/src/report/web/primitives/PersonRow.tsx +++ b/src/report/web/primitives/PersonRow.tsx @@ -4,11 +4,12 @@ interface Props { login: string; mergedCount: number; reviewedCount: number; + windowHours: number; windowCostUsd: number; annualCostUsd: number; } -export function PersonRow({ login, mergedCount, reviewedCount, windowCostUsd, annualCostUsd }: Props) { +export function PersonRow({ login, mergedCount, reviewedCount, windowHours, windowCostUsd, annualCostUsd }: Props) { return ( {login} @@ -17,6 +18,7 @@ export function PersonRow({ login, mergedCount, reviewedCount, windowCostUsd, an {mergedCount > 0 && reviewedCount > 0 ? , : null} + {windowHours.toLocaleString()} {fmtUsd(windowCostUsd)} {fmtUsd(annualCostUsd)} diff --git a/src/report/web/primitives/StackedBar.tsx b/src/report/web/primitives/StackedBar.tsx index 841b7eb..bf03e9f 100644 --- a/src/report/web/primitives/StackedBar.tsx +++ b/src/report/web/primitives/StackedBar.tsx @@ -5,13 +5,17 @@ interface Props { low: number; } -const SEGMENTS = [ - { key: 'critical', label: 'Critical', cssVar: 'var(--red)' }, - { key: 'high', label: 'High', cssVar: 'var(--tangerine)' }, - { key: 'medium', label: 'Medium', cssVar: 'var(--color-amber-600)' }, - { key: 'low', label: 'Low', cssVar: 'var(--color-neutral-400)' }, +// Severity palette, ordered most-to-least severe. Shared with RiskStory's +// per-repo bars so a severity is the same color everywhere. +export const SEGMENTS = [ + { key: 'critical', label: 'Critical', cssVar: 'var(--severity-critical)' }, + { key: 'high', label: 'High', cssVar: 'var(--severity-high)' }, + { key: 'medium', label: 'Medium', cssVar: 'var(--severity-medium)' }, + { key: 'low', label: 'Low', cssVar: 'var(--severity-low)' }, ] as const; +export type SeverityCounts = Record<(typeof SEGMENTS)[number]['key'], number>; + export function StackedBar({ critical, high, medium, low }: Props) { const total = critical + high + medium + low; if (total === 0) { diff --git a/src/report/web/styles.css b/src/report/web/styles.css index dbf187c..0c09c7d 100644 --- a/src/report/web/styles.css +++ b/src/report/web/styles.css @@ -94,6 +94,18 @@ --destructive: var(--red); --input: var(--color-neutral-200); --ring: var(--color-neutral-400); + + /* Savings highlight. A lighter green reads on the dark palette but washes out + on white, so light mode uses a darker green for contrast. */ + --savings: var(--color-green-700); + + /* CVE severity palette from GitHub's Primer severity roles (Critical=danger, + High=severe, Medium=attention, Low=neutral). Vivid enough to read as solid + fills on both backgrounds, so one set serves light and dark. */ + --severity-critical: #f85149; + --severity-high: #db6d28; + --severity-medium: #d29922; + --severity-low: #8b949e; } /* Dark palette. Applies when the OS asks for it (unless a Storybook story forces @@ -119,6 +131,7 @@ --input: var(--color-neutral-800); --ring: var(--color-neutral-600); + --savings: var(--color-green-400); } } @@ -140,6 +153,7 @@ --input: var(--color-neutral-800); --ring: var(--color-neutral-600); + --savings: var(--color-green-400); } @theme inline { @@ -164,6 +178,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-savings: var(--savings); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring);