From 28222bac91d82e49752159d6ab5500d74c9e0b1a Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Wed, 27 May 2026 08:59:30 -0600 Subject: [PATCH 1/5] feat(report): lead with the solution and apply storybook review feedback Restructures the HTML report around reviewer feedback on the Chromatic story: the solution now leads, the assumptions are a hero-level control, and several sections are reworked. - Hero: drop the secondary stat line; surface the global assumptions as a collapsed disclosure (numbers shown, expands to edit) - Lead with the 'Today vs. PatchWave auto-merge' solution section; the full waitlist CTA closes the report above the methodology, with a short 'Join the waitlist' button ending the solution section - Calculator: tick dots at slider break points, relabel the center metric to 'PRs auto-merged' to match the slider, and a theme-aware --savings green for light-mode contrast - Cost table: add a Time (hrs) column - Collapse the 90-180/180+ PR-age buckets into a single 90+ bucket - Render top repos by severity as a horizontal stacked-bar histogram - Derive the story's global CVE totals from the per-repo rows so they reconcile - Remove the adjustable-assumptions footnote now that the control is in the hero --- src/report/aggregate.test.ts | 4 +- src/report/aggregate.ts | 3 +- src/report/testFactories.ts | 3 +- src/report/web/App.browser.test.tsx | 55 ++--------- src/report/web/App.stories.tsx | 38 ++++++-- src/report/web/App.tsx | 2 +- src/report/web/acts/AutomatedStory.tsx | 53 +++++++---- src/report/web/acts/CostStory.tsx | 10 +- src/report/web/acts/MethodologyAppendix.tsx | 26 +++-- src/report/web/acts/RiskStory.tsx | 95 ++++++++++--------- src/report/web/acts/Verdict.tsx | 59 +----------- .../web/hooks/useAssumptionsDisclosure.tsx | 3 - .../web/primitives/AssumptionsFootnote.tsx | 22 ----- .../web/primitives/FootnoteReference.tsx | 5 +- src/report/web/primitives/HeroAssumptions.tsx | 30 ++++++ src/report/web/primitives/PersonRow.tsx | 4 +- src/report/web/primitives/StackedBar.tsx | 6 +- src/report/web/styles.css | 7 ++ 18 files changed, 193 insertions(+), 232 deletions(-) delete mode 100644 src/report/web/primitives/AssumptionsFootnote.tsx create mode 100644 src/report/web/primitives/HeroAssumptions.tsx diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index 1f6303d..95f5d5e 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -35,8 +35,8 @@ 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); }); diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index 1c4364c..90bba7c 100644 --- a/src/report/aggregate.ts +++ b/src/report/aggregate.ts @@ -235,8 +235,7 @@ 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 openAgeBuckets = buckets.map((b) => ({ label: b.label, diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index 5394c07..7e739ef 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -56,8 +56,7 @@ 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, bumpTypeSplit: [ diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index 0c09d8a..f124fe7 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'; @@ -73,52 +72,23 @@ describe('App report shell', () => { 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(); - }); - it('lists footnotes in ascending first-appearance order', () => { renderReport(); 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 +106,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 +165,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 +181,7 @@ 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.section)).toHaveTextContent('based on adjustable assumptions'); 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,16 +197,6 @@ 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(); @@ -306,7 +263,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' }, 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/CostStory.tsx b/src/report/web/acts/CostStory.tsx index 9826c01..f771602 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:

@@ -95,7 +93,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 +118,7 @@ function PeopleTable({ windowDays }: { windowDays: number }) { Person Count + Time (hrs) Cost over last {windowDays} days Annualized @@ -131,13 +130,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'; return ( diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 169ca83..9355efc 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -1,14 +1,12 @@ import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; 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,7 +14,6 @@ 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() { @@ -34,11 +31,12 @@ export function Verdict() { /year

- {verdictCopy.costTrailer}, based on adjustable - assumptions + {verdictCopy.costTrailer}, based on adjustable assumptions

- +
+ +

- The adjustable 5 min/PR default is deliberately low. The adjustable $150/hr default reflects a $300k - engineer cost divided by 2,000 working hours. + The adjustable 12 min/PR default covers the context switch, review, and merge for a single PR. The + adjustable $200/hr default reflects a $400k fully-loaded engineer cost divided by 2,000 working + hours.

Estimates only; real savings vary by team. The defaults are intentionally conservative diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 9355efc..0f55447 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -30,9 +30,7 @@ export function Verdict() { ~{fmtUsd(derived.annualCostUsd)} /year -

- {verdictCopy.costTrailer}, based on adjustable assumptions -

+

{verdictCopy.costTrailer}

diff --git a/src/report/web/primitives/HeroAssumptions.tsx b/src/report/web/primitives/HeroAssumptions.tsx index ed4a1b4..a7dee72 100644 --- a/src/report/web/primitives/HeroAssumptions.tsx +++ b/src/report/web/primitives/HeroAssumptions.tsx @@ -8,21 +8,23 @@ export const heroAssumptionsTestIds = { export function HeroAssumptions() { const { assumptions } = useAssumptions(); return ( -
+
- Assumes ${assumptions.hourlyRateUsd}/hr{' '} - and {assumptions.minutesPerPr} min per PR + Assumes the loaded cost of engineering is{' '} + ${assumptions.hourlyRateUsd}/hr and it + takes {assumptions.minutesPerPr} minutes{' '} + to review each PR Adjust Done -
+
From 1b2569fc66018c49a84c8a2e2a97c69248583a5a Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Wed, 27 May 2026 10:08:31 -0600 Subject: [PATCH 3/5] feat(report): match CVE severity bars to GitHub's colors Introduce a dedicated --severity-* palette pulled from GitHub's Primer severity roles (danger/severe/attention/neutral) and point the stacked bar, per-repo bars, and the oldest-open age cells at it, so severities read the same here as in GitHub's security UI in both light and dark mode. --- src/report/web/acts/RiskStory.tsx | 6 ++++-- src/report/web/primitives/StackedBar.tsx | 8 ++++---- src/report/web/styles.css | 8 ++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/report/web/acts/RiskStory.tsx b/src/report/web/acts/RiskStory.tsx index 01a73f8..3886cf3 100644 --- a/src/report/web/acts/RiskStory.tsx +++ b/src/report/web/acts/RiskStory.tsx @@ -218,11 +218,13 @@ function repoTotal(r: RepoSeverityRow): number { } 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/primitives/StackedBar.tsx b/src/report/web/primitives/StackedBar.tsx index 9fdf5ce..bf03e9f 100644 --- a/src/report/web/primitives/StackedBar.tsx +++ b/src/report/web/primitives/StackedBar.tsx @@ -8,10 +8,10 @@ interface Props { // 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(--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)' }, + { 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>; diff --git a/src/report/web/styles.css b/src/report/web/styles.css index 8e6ee6a..0c09c7d 100644 --- a/src/report/web/styles.css +++ b/src/report/web/styles.css @@ -98,6 +98,14 @@ /* 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 From 23f295d30dad90eadf0a8b114af023903621300d Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Wed, 27 May 2026 09:27:09 -0600 Subject: [PATCH 4/5] fix(report): drop arrow from waitlist CTA label --- src/report/web/acts/CallToAction.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() { From c52e63736fc1972f9002e10e611a17c221913725 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Wed, 27 May 2026 10:50:43 -0600 Subject: [PATCH 5/5] feat(report): note the headline excludes open PRs and surface the backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline cost counts only merged PRs, so it now says so — "(not including the N still open)" — framing the number as a defensible floor rather than appearing to ignore the backlog. The Open PR age section now leads with the count still open and their average age, computed from a new PrBacklog.openAvgAgeDays. The methodology appendix notes that open PRs are excluded from the cost and reported there instead. --- src/report/aggregate.test.ts | 16 ++++++++++++++++ src/report/aggregate.ts | 13 +++++++------ src/report/testFactories.ts | 1 + src/report/web/App.browser.test.tsx | 9 +++++++++ src/report/web/acts/MethodologyAppendix.tsx | 4 ++++ src/report/web/acts/OpenPrAgeStory.tsx | 20 ++++++++++++++++++++ src/report/web/acts/Verdict.tsx | 17 ++++++++++++----- 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index b5b7e4d..714a902 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -40,6 +40,22 @@ test('counts merged-in-window PRs and surfaces backlog age buckets', () => { 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: [ diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index 90bba7c..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 }; @@ -237,16 +238,15 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant) { label: '60–90 days', min: 60, max: 90 }, { 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) { @@ -299,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/testFactories.ts b/src/report/testFactories.ts index 1d312d3..c4b4a25 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -60,6 +60,7 @@ export const prBacklog = Factory.define(() => ({ { label: '90+ days', count: 38 }, ], oldestOpenDays: 312, + openAvgAgeDays: 74, bumpTypeSplit: [ { bumpType: 'patch', count: 150, percentage: 55 }, { bumpType: 'minor', count: 95, percentage: 34.8 }, diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index 9b9c862..ce10e7d 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -26,6 +26,8 @@ describe('App report shell', () => { 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', () => { @@ -286,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'); @@ -299,6 +306,7 @@ describe('App report shell', () => { ...embeddedReportData.build().prBacklog, openCount: 0, oldestOpenDays: null, + openAvgAgeDays: null, openAgeBuckets: [], }, }); @@ -307,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/acts/MethodologyAppendix.tsx b/src/report/web/acts/MethodologyAppendix.tsx index a787761..61bfc73 100644 --- a/src/report/web/acts/MethodologyAppendix.tsx +++ b/src/report/web/acts/MethodologyAppendix.tsx @@ -105,6 +105,10 @@ export function MethodologyAppendix() { } /> + + {hasOpenPrs && pr.openAvgAgeDays !== null && ( +
+ + +
+ )} +
{!hasOpenPrs ? (
@@ -63,6 +74,15 @@ export function OpenPrAgeStory() { ); } +function BacklogStat({ value, label, className }: { value: string; label: string; className?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + function AgeBucketRow({ label, count, total }: { label: string; count: number; total: number }) { const pct = total === 0 ? 0 : Math.round((count / total) * 100); return ( diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 0f55447..923f20a 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -1,5 +1,6 @@ import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; 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 { HeroAssumptions } from '../primitives/HeroAssumptions.tsx'; @@ -18,6 +19,7 @@ export const verdictCopy = { export function Verdict() { const { derived } = useAssumptions(); + const { openCount } = useEmbeddedData().prBacklog; const analytics = useAnalytics(); return ( @@ -30,11 +32,12 @@ export function Verdict() { ~{fmtUsd(derived.annualCostUsd)} /year -

{verdictCopy.costTrailer}

- -
- -
+

+ {verdictCopy.costTrailer} + {openCount > 0 && ( + (not including the {openCount.toLocaleString()} still open) + )} +

+ +
+ +
); }