From 2378979abffb0e431a0bc7a465422c60bd3e61be Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Thu, 28 May 2026 10:40:23 -0700 Subject: [PATCH 1/2] refactor: Show time savings --- src/report/aggregate.test.ts | 16 ++-- src/report/costFormulas.ts | 4 +- src/report/testFactories.ts | 6 +- src/report/web/App.browser.test.tsx | 77 ++++++++++--------- src/report/web/acts/AutomatedStory.tsx | 37 +++++---- src/report/web/acts/CostStory.tsx | 39 ++++++---- src/report/web/acts/MethodologyAppendix.tsx | 28 +++---- src/report/web/acts/Verdict.tsx | 20 +++-- src/report/web/assumptionFields.ts | 1 - src/report/web/hooks/useAssumptions.tsx | 24 ++---- src/report/web/primitives/AssumptionInput.tsx | 10 +-- src/report/web/primitives/HeroAssumptions.tsx | 7 +- src/report/web/primitives/PersonRow.tsx | 10 +-- 13 files changed, 131 insertions(+), 148 deletions(-) diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index 3804333..867686b 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -214,14 +214,16 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges expect(bundle.costEstimate.humanMergeCount).toBe(100); expect(bundle.costEstimate.humanReviewCount).toBe(0); 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.minutesPerPr).toBe(10); + // 100 actions × 10 min × $200/hr / 60 = $3333 in window + expect(bundle.costEstimate.windowCostUsd).toBe(3333); + // ~$1,126/month over 90 days (window × 30.44/90) + expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1100); + expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1150); 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.map((s) => s.autoMergeRate)).toEqual([ + 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, + ]); expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe( (bundle.costEstimate.savingsScenarios[0]?.monthlySavingsUsd ?? 0) * 12, ); diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts index f2590a6..aa3e2b7 100644 --- a/src/report/costFormulas.ts +++ b/src/report/costFormulas.ts @@ -3,9 +3,9 @@ // so it bundles cleanly into both targets. export const ASSUMED_HOURLY_RATE_USD = 200; -export const ASSUMED_MIN_PER_PR = 12; +export const ASSUMED_MIN_PER_PR = 10; -export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const; +export const AUTO_MERGE_SCENARIO_RATES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] as const; const DAYS_PER_MONTH = 365 / 12; diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index 81353da..3d79224 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -80,14 +80,14 @@ export const stalledSignals = Factory.define(() => ({ // 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_MERGE_COUNT = 288; const HUMAN_REVIEW_COUNT = 12; export const people = Factory.define(() => ({ mergers: derivePersonCosts( [ - { login: 'alice', count: 90 }, - { login: 'bob', count: 60 }, + { login: 'alice', count: 180 }, + { login: 'bob', count: 108 }, ], COST_WINDOW_DAYS, ASSUMED_MIN_PER_PR, diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index a9c2c0f..8fc1442 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -23,7 +23,7 @@ describe('App report shell', () => { it('renders the headline annual cost from the embedded data', () => { renderReport(); - expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$26,280/year'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('50 engineer-hours/quarter'); 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. @@ -34,44 +34,46 @@ describe('App report shell', () => { renderReport(); const assumptions = screen.getByTestId(assumptionInputTestIds.container); - fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate), { - target: { value: '300' }, + fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), { + target: { value: '12' }, }); - 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('$39,420/yr'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$25,623/yr'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter'); + expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('60 hrs'); + expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('20 hrs/mo'); + expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('60 hrs/qtr'); + // The automation comparison defaults to recovered engineer hours per quarter. + expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('~60 hrs/qtr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~36 hrs/qtr'); }); it('allows replacing an assumption value by clearing and typing', () => { renderReport(); const assumptions = screen.getByTestId(assumptionInputTestIds.container); - const hourlyRateInput = within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate); + const minutesInput = within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr); - fireEvent.focus(hourlyRateInput); - fireEvent.change(hourlyRateInput, { target: { value: '' } }); - expect(hourlyRateInput).toHaveValue(''); + fireEvent.focus(minutesInput); + fireEvent.change(minutesInput, { target: { value: '' } }); + expect(minutesInput).toHaveValue(''); - fireEvent.change(hourlyRateInput, { target: { value: '275' } }); - fireEvent.blur(hourlyRateInput); + fireEvent.change(minutesInput, { target: { value: '12' } }); + fireEvent.blur(minutesInput); - expect(hourlyRateInput).toHaveValue('275'); - expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$36,132/year'); + expect(minutesInput).toHaveValue('12'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter'); }); it('recalculates the PatchWave savings card when the auto-merge share changes', () => { renderReport(); - // Default 65% share starts in the middle of the modeled range. - expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,082/yr'); + // Default 60% share matches PatchWave's public calculator assumption. + expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('60%'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~30 hrs/qtr'); fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } }); expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%'); - expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$13,140/yr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~25 hrs/qtr'); }); it('lists footnotes in ascending first-appearance order', () => { @@ -206,12 +208,12 @@ describe('App report shell', () => { renderReport(); const table = screen.getByTestId(costStoryTestIds.peopleTable); - expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument(); + expect(within(table).getByRole('columnheader', { name: 'Time over last 90 days' })).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('180'); expect(aliceRow).toHaveTextContent('merged'); expect(aliceRow).toHaveTextContent('12'); expect(aliceRow).toHaveTextContent('reviewed'); @@ -240,7 +242,7 @@ describe('App report shell', () => { expect(within(table).getByText('person-6')).toBeInTheDocument(); }); - it('reworks per-person review costs when the minutes-per-PR assumption changes', () => { + it('reworks per-person review time when the minutes-per-PR assumption changes', () => { renderReport({ people: { mergers: [], @@ -250,20 +252,20 @@ describe('App report shell', () => { }); const table = screen.getByTestId(costStoryTestIds.peopleTable); - // 10 reviews x 12 min x $200/hr / 60 = $400 in window at the defaults. - expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$400'); + // 10 reviews x 10 min / 60 = ~2 hours in window at the defaults. + expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('2'); const assumptions = screen.getByTestId(assumptionInputTestIds.container); fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), { - target: { value: '10' }, + target: { value: '20' }, }); - // 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'); + // Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the time: + // 10 reviews x 20 min / 60 = ~3 hours. + expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('3'); }); - it('reworks the raw-data per-person costs when an assumption changes', () => { + it('keeps raw-data people rows count-only when an assumption changes', () => { renderReport({ people: { mergers: [], @@ -275,15 +277,16 @@ describe('App report shell', () => { // 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' }, + target: { value: '20' }, }); fireEvent.click(screen.getByText('How this report was calculated')); fireEvent.click(screen.getByRole('tab', { name: 'Raw data' })); - // 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,351/yr'); + const row = within(rawData).getByText('carol').closest('li'); + expect(row).toHaveTextContent('10 reviewed'); + expect(row).not.toHaveTextContent('$'); }); it('renders the open PR age buckets as a separate section with count-only rows', () => { @@ -393,14 +396,14 @@ describe('App analytics', () => { const { analytics, events } = createFakeAnalytics(); renderWithAnalytics(analytics); - const input = screen.getAllByTestId(assumptionInputTestIds.hourlyRate)[0]; - if (!input) throw new Error('missing hourly rate input'); - fireEvent.change(input, { target: { value: '275' } }); + const input = screen.getAllByTestId(assumptionInputTestIds.minutesPerPr)[0]; + if (!input) throw new Error('missing minutes per PR input'); + fireEvent.change(input, { target: { value: '12' } }); fireEvent.blur(input); expect(events).toContainEqual({ event: 'assumption_changed', - properties: { field: 'hourly_rate', value: 275 }, + properties: { field: 'minutes_per_pr', value: 12 }, }); }); }); diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx index bf8fd2f..5bfaeb3 100644 --- a/src/report/web/acts/AutomatedStory.tsx +++ b/src/report/web/acts/AutomatedStory.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; import { Button } from '../components/ui/button.tsx'; -import { fmtUsd } from '../format/money.ts'; +import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; import { Citation } from '../primitives/Citation.tsx'; import { callToActionCopy } from './CallToAction.tsx'; @@ -15,10 +15,10 @@ export const automatedStoryTestIds = { waitlistCta: 'automated-story-waitlist-cta', } as const; -const SHARE_MIN = 50; -const SHARE_MAX = 80; +const SHARE_MIN = 10; +const SHARE_MAX = 90; const SHARE_STEP = 5; -const SHARE_DEFAULT = 65; +const SHARE_DEFAULT = 60; const SHARE_MID = (SHARE_MIN + SHARE_MAX) / 2; const SHARE_STOPS = Array.from( { length: (SHARE_MAX - SHARE_MIN) / SHARE_STEP + 1 }, @@ -26,12 +26,14 @@ const SHARE_STOPS = Array.from( ); export function AutomatedStory() { - const { assumptions, derived } = useAssumptions(); + const { assumptions } = useAssumptions(); + const { costEstimate } = useEmbeddedData(); const analytics = useAnalytics(); const [sharePct, setSharePct] = useState(SHARE_DEFAULT); - const todayCost = derived.annualCostUsd; - const patchwaveSavings = Math.round(todayCost * (sharePct / 100)); + const totalActions = costEstimate.humanMergeCount + costEstimate.humanReviewCount; + const quarterlyHours = actionsToHours(totalActions, assumptions.minutesPerPr); + const patchwaveSavingsHours = Math.round(quarterlyHours * (sharePct / 100)); return (
@@ -51,8 +53,8 @@ export function AutomatedStory() {
@@ -151,14 +153,11 @@ function CompareCard({ ); } -function quarterHoursLabel(costUsd: number, hourlyRateUsd: number): string { - const hours = Math.round(costUsd / hourlyRateUsd / 4); - if (hours <= 0) return 'under an hour of engineer time per quarter'; - return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time per quarter`; +function actionsToHours(count: number, minutesPerPr: number): number { + return Math.round((count * minutesPerPr) / 60); } -function quarterHoursSavedLabel(savingsUsd: number, hourlyRateUsd: number): string { - const hours = Math.round(savingsUsd / hourlyRateUsd / 4); - if (hours <= 0) return 'under an hour of engineer time saved per quarter'; - return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time saved per quarter`; +function fmtHoursPerQuarter(hours: number): string { + if (hours <= 0) return '<1 hr/qtr'; + return `~${hours.toLocaleString()} ${hours === 1 ? 'hr' : 'hrs'}/qtr`; } diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx index 0acc250..949dc60 100644 --- a/src/report/web/acts/CostStory.tsx +++ b/src/report/web/acts/CostStory.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; -import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; import { PersonRow } from '../primitives/PersonRow.tsx'; @@ -24,7 +23,10 @@ export function CostStory() { const data = useEmbeddedData(); const { assumptions, derived } = useAssumptions(); - const { humanMergeCount } = data.costEstimate; + const { humanMergeCount, humanReviewCount } = data.costEstimate; + const totalActions = humanMergeCount + humanReviewCount; + const windowHours = actionsToHours(totalActions, assumptions.minutesPerPr); + const monthlyHours = (windowHours * (365 / 12)) / data.costEstimate.windowDays; return (
@@ -39,25 +41,25 @@ 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{' '} - {assumptions.minutesPerPr} minutes per PR and{' '} - ${assumptions.hourlyRateUsd}/hr, that comes out to: + {assumptions.minutesPerPr} minutes per PR, that comes out + to:

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

- The 12 min/PR default covers the context switch, review, and merge for a single PR. Anything a bot merged is + The 10 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.

@@ -93,6 +95,15 @@ function CostCell({ ); } +function actionsToHours(count: number, minutesPerPr: number): number { + return (count * minutesPerPr) / 60; +} + +function fmtHours(hours: number): string { + const rounded = Math.round(hours); + return `${rounded.toLocaleString()} ${rounded === 1 ? 'hr' : 'hrs'}`; +} + function PeopleTable({ windowDays }: { windowDays: number }) { const { assumptions, derived } = useAssumptions(); const [expanded, setExpanded] = useState(false); @@ -119,9 +130,7 @@ function PeopleTable({ windowDays }: { windowDays: number }) { Person Count - Time (hrs) - Cost over last {windowDays} days - Annualized + Time over last {windowDays} days @@ -131,14 +140,12 @@ 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} + windowHours={Math.round(actionsToHours(r.mergedCount + r.reviewedCount, assumptions.minutesPerPr))} /> ))} {hiddenCount > 0 || expanded ? ( - +
@@ -93,14 +91,13 @@ export function MethodologyAppendix() { - (Human merges + reviews) × minutes per PR ÷ 60 × hourly rate. - Bot-merged PRs are excluded. Currently using adjustable assumptions:{' '} + (Human merges + reviews) × minutes per PR ÷ 60. Bot-merged PRs are excluded. + Currently using adjustable assumptions:{' '} {data.costEstimate.humanMergeCount + data.costEstimate.humanReviewCount} actions ×{' '} - {assumptions.minutesPerPr} min ÷ 60 × ${assumptions.hourlyRateUsd} - /hr. + {assumptions.minutesPerPr} min ÷ 60. } /> @@ -110,11 +107,11 @@ export function MethodologyAppendix() { />

- 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. + The adjustable 10 min/PR default covers the context switch, review, and merge for a single PR.

Estimates only; real savings vary by team. The defaults are intentionally conservative @@ -464,7 +459,7 @@ function PeopleList({ countLabel, }: { label: string; - rows: ReadonlyArray<{ login: string; count: number; annualCostUsd?: number }>; + rows: ReadonlyArray<{ login: string; count: number }>; countLabel: string; }) { return ( @@ -479,7 +474,6 @@ function PeopleList({ {row.login} {row.count} {countLabel} - {row.annualCostUsd !== undefined ? `, ${fmtUsd(row.annualCostUsd)}/yr` : ''} ))} diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 923f20a..5b80d23 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -1,7 +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,9 +17,12 @@ export const verdictCopy = { } as const; export function Verdict() { - const { derived } = useAssumptions(); - const { openCount } = useEmbeddedData().prBacklog; + const { assumptions } = useAssumptions(); + const { costEstimate, prBacklog } = useEmbeddedData(); + const { openCount } = prBacklog; const analytics = useAnalytics(); + const totalActions = costEstimate.humanMergeCount + costEstimate.humanReviewCount; + const quarterlyHours = actionsToHours(totalActions, assumptions.minutesPerPr); return (

@@ -29,8 +31,8 @@ export function Verdict() { data-testid={verdictTestIds.annualCost} className="text-foreground mt-2 text-5xl leading-none font-medium tracking-tight tabular-nums sm:text-7xl" > - ~{fmtUsd(derived.annualCostUsd)} - /year + ~{fmtHours(quarterlyHours)} + /quarter

{verdictCopy.costTrailer} @@ -55,3 +57,11 @@ export function Verdict() {

); } + +function actionsToHours(count: number, minutesPerPr: number): number { + return (count * minutesPerPr) / 60; +} + +function fmtHours(hours: number): string { + return `${Math.round(hours).toLocaleString()} engineer-hours`; +} diff --git a/src/report/web/assumptionFields.ts b/src/report/web/assumptionFields.ts index fd4f34e..867dcf6 100644 --- a/src/report/web/assumptionFields.ts +++ b/src/report/web/assumptionFields.ts @@ -13,6 +13,5 @@ export interface AssumptionField { } export const assumptionFields = { - hourlyRateUsd: { label: 'Loaded hourly rate', prefix: '$', suffix: '/hr', min: 1, max: 1000, step: 5 }, minutesPerPr: { label: 'Minutes per PR', min: 1, max: 240, step: 1 }, } as const satisfies Record; diff --git a/src/report/web/hooks/useAssumptions.tsx b/src/report/web/hooks/useAssumptions.tsx index c4b09ac..b6bea53 100644 --- a/src/report/web/hooks/useAssumptions.tsx +++ b/src/report/web/hooks/useAssumptions.tsx @@ -4,7 +4,6 @@ import { assumptionFields } from '../assumptionFields.ts'; import type { EmbeddedReportData } from '../types.ts'; export interface Assumptions { - hourlyRateUsd: number; minutesPerPr: number; } @@ -21,7 +20,6 @@ type ValueUpdate = number | ((prev: number) => number); interface ContextValue { assumptions: Assumptions; - setHourlyRate: (next: ValueUpdate) => void; setMinutesPerPr: (next: ValueUpdate) => void; reset: () => void; derived: DerivedCost; @@ -31,22 +29,9 @@ const Ctx = createContext(null); export function AssumptionsProvider({ data, children }: { data: EmbeddedReportData; children: ReactNode }) { const defaults: Assumptions = { - hourlyRateUsd: data.costEstimate.hourlyRateUsd, minutesPerPr: data.costEstimate.minutesPerPr, }; const [assumptions, setAssumptions] = useState(defaults); - const setHourlyRate = useCallback( - (next: ValueUpdate) => - setAssumptions((prev) => ({ - ...prev, - hourlyRateUsd: clamp( - resolve(next, prev.hourlyRateUsd), - assumptionFields.hourlyRateUsd.min, - assumptionFields.hourlyRateUsd.max, - ), - })), - [], - ); const setMinutesPerPr = useCallback( (next: ValueUpdate) => setAssumptions((prev) => ({ @@ -59,13 +44,14 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa })), [], ); - const reset = useCallback(() => setAssumptions(defaults), [defaults.hourlyRateUsd, defaults.minutesPerPr]); + const reset = useCallback(() => setAssumptions(defaults), [defaults.minutesPerPr]); const derived = useMemo(() => { - const { hourlyRateUsd, minutesPerPr } = assumptions; + const { minutesPerPr } = assumptions; + const hourlyRateUsd = data.costEstimate.hourlyRateUsd; const totalActions = data.costEstimate.humanMergeCount + data.costEstimate.humanReviewCount; const windowDays = data.costEstimate.windowDays; - const cost = deriveCostEstimate(totalActions, windowDays, assumptions); + const cost = deriveCostEstimate(totalActions, windowDays, { minutesPerPr, hourlyRateUsd }); return { ...cost, mergers: derivePersonCosts(data.people.mergers, windowDays, minutesPerPr, hourlyRateUsd), @@ -73,7 +59,7 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa }; }, [assumptions, data]); - const value: ContextValue = { assumptions, setHourlyRate, setMinutesPerPr, reset, derived }; + const value: ContextValue = { assumptions, setMinutesPerPr, reset, derived }; return {children}; } diff --git a/src/report/web/primitives/AssumptionInput.tsx b/src/report/web/primitives/AssumptionInput.tsx index e78f105..2078e90 100644 --- a/src/report/web/primitives/AssumptionInput.tsx +++ b/src/report/web/primitives/AssumptionInput.tsx @@ -6,26 +6,18 @@ import { NumberStepper } from './NumberStepper.tsx'; export const assumptionInputTestIds = { container: 'assumption-input-container', - hourlyRate: 'assumption-input-hourly-rate', minutesPerPr: 'assumption-input-minutes-per-pr', reset: 'assumption-input-reset', } as const; export function AssumptionInput() { - const { assumptions, setHourlyRate, setMinutesPerPr, reset } = useAssumptions(); + const { assumptions, setMinutesPerPr, reset } = useAssumptions(); const analytics = useAnalytics(); return (
- analytics.capture('assumption_changed', { field: 'hourly_rate', value })} - {...assumptionFields.hourlyRateUsd} - /> - Assumes the loaded cost of engineering is{' '} - ${assumptions.hourlyRateUsd}/hr and it - takes {assumptions.minutesPerPr} minutes{' '} - to review each PR + Assumes it takes{' '} + {assumptions.minutesPerPr} minutes to + review each PR Adjust diff --git a/src/report/web/primitives/PersonRow.tsx b/src/report/web/primitives/PersonRow.tsx index 27f7eb1..3f7006a 100644 --- a/src/report/web/primitives/PersonRow.tsx +++ b/src/report/web/primitives/PersonRow.tsx @@ -1,15 +1,11 @@ -import { fmtUsd } from '../format/money.ts'; - interface Props { login: string; mergedCount: number; reviewedCount: number; windowHours: number; - windowCostUsd: number; - annualCostUsd: number; } -export function PersonRow({ login, mergedCount, reviewedCount, windowHours, windowCostUsd, annualCostUsd }: Props) { +export function PersonRow({ login, mergedCount, reviewedCount, windowHours }: Props) { return (
@@ -19,10 +15,6 @@ export function PersonRow({ login, mergedCount, reviewedCount, windowHours, wind - - ); } From 33fe2a7e049955d7a9eabc6b6da797372908e37b Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Thu, 28 May 2026 11:32:44 -0700 Subject: [PATCH 2/2] chore: Add toggle for cost and dollars --- src/report/web/App.browser.test.tsx | 40 +++++++++++--- src/report/web/acts/AutomatedStory.tsx | 52 +++++++++++++----- src/report/web/acts/CostStory.tsx | 35 ++++++++---- src/report/web/acts/Verdict.tsx | 24 +++------ src/report/web/assumptionFields.ts | 1 + src/report/web/hooks/useAssumptions.tsx | 37 +++++++++++-- src/report/web/primitives/AssumptionInput.tsx | 54 +++++++++++++++++-- src/report/web/primitives/HeroAssumptions.tsx | 28 ++-------- src/report/web/primitives/NumberStepper.tsx | 12 +++-- src/report/web/primitives/PersonRow.tsx | 6 +-- 10 files changed, 200 insertions(+), 89 deletions(-) diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index 8fc1442..e98f312 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea import { afterEach, describe, expect, it } from 'vitest'; import type { Analytics } from '../../Analytics.ts'; import { cveExposureOk, embeddedReportData } from '../testFactories.ts'; -import { automatedStoryTestIds } from './acts/AutomatedStory.tsx'; +import { automatedStoryCopy, automatedStoryTestIds } from './acts/AutomatedStory.tsx'; import { callToActionCopy, callToActionTestIds } from './acts/CallToAction.tsx'; import { costStoryCopy, costStoryTestIds } from './acts/CostStory.tsx'; import { methodologyAppendixTestIds } from './acts/MethodologyAppendix.tsx'; @@ -28,6 +28,9 @@ describe('App report shell', () => { 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'); + expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument(); + expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as time'); + expect(screen.queryByTestId(assumptionInputTestIds.hourlyRateUsd)).not.toBeInTheDocument(); }); it('recalculates the headline cost and comparison cards when assumptions change', () => { @@ -47,6 +50,26 @@ describe('App report shell', () => { expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~36 hrs/qtr'); }); + it('toggles report savings from time to cost', () => { + renderReport(); + const assumptions = screen.getByTestId(assumptionInputTestIds.container); + + fireEvent.click(within(assumptions).getByTestId(assumptionInputTestIds.displayCost)); + + expect(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRateUsd)).toBeInTheDocument(); + expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as cost'); + expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$40,560/year'); + expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('$10,000'); + expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('$3,380/mo'); + expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$40,560/yr'); + expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$40,560/yr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$24,336/yr'); + + const table = screen.getByTestId(costStoryTestIds.peopleTable); + expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument(); + expect(within(table).getByText('alice').closest('tr')).toHaveTextContent('$6,400'); + }); + it('allows replacing an assumption value by clearing and typing', () => { renderReport(); const assumptions = screen.getByTestId(assumptionInputTestIds.container); @@ -189,8 +212,13 @@ 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.primaryCta)).toHaveTextContent(verdictCopy.primaryCta); - expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default'); + expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveTextContent(automatedStoryCopy.secondaryCta); + expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveAttribute('data-variant', 'secondary'); + expect( + screen + .getByTestId(automatedStoryTestIds.waitlistCta) + .compareDocumentPosition(screen.getByTestId(automatedStoryTestIds.secondaryCta)), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel); fireEvent.click(screen.getByText('How this report was calculated')); @@ -370,15 +398,15 @@ describe('App analytics', () => { cleanup(); }); - it('captures cta_clicked when the verdict primary CTA is clicked', () => { + it('captures cta_clicked when the automated story secondary CTA is clicked', () => { const { analytics, events } = createFakeAnalytics(); renderWithAnalytics(analytics); const restore = suppressNavigation(); - fireEvent.click(screen.getByTestId(verdictTestIds.primaryCta)); + fireEvent.click(screen.getByTestId(automatedStoryTestIds.secondaryCta)); restore(); - expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'verdict_primary' } }); + expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'automated_story_secondary' } }); }); it('captures cta_clicked when the call-to-action CTA is clicked', () => { diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx index 5bfaeb3..8d28baa 100644 --- a/src/report/web/acts/AutomatedStory.tsx +++ b/src/report/web/acts/AutomatedStory.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; 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 { Citation } from '../primitives/Citation.tsx'; import { callToActionCopy } from './CallToAction.tsx'; @@ -13,6 +14,11 @@ export const automatedStoryTestIds = { delta: 'automated-story-delta', shareSlider: 'automated-story-share-slider', waitlistCta: 'automated-story-waitlist-cta', + secondaryCta: 'automated-story-secondary-cta', +} as const; + +export const automatedStoryCopy = { + secondaryCta: 'See how PatchWave helps', } as const; const SHARE_MIN = 10; @@ -26,7 +32,7 @@ const SHARE_STOPS = Array.from( ); export function AutomatedStory() { - const { assumptions } = useAssumptions(); + const { assumptions, displayMode, derived } = useAssumptions(); const { costEstimate } = useEmbeddedData(); const analytics = useAnalytics(); const [sharePct, setSharePct] = useState(SHARE_DEFAULT); @@ -34,6 +40,7 @@ export function AutomatedStory() { const totalActions = costEstimate.humanMergeCount + costEstimate.humanReviewCount; const quarterlyHours = actionsToHours(totalActions, assumptions.minutesPerPr); const patchwaveSavingsHours = Math.round(quarterlyHours * (sharePct / 100)); + const patchwaveSavingsUsd = Math.round(derived.annualCostUsd * (sharePct / 100)); return (
@@ -53,8 +60,12 @@ export function AutomatedStory() {
); } diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx index 949dc60..1805a8b 100644 --- a/src/report/web/acts/CostStory.tsx +++ b/src/report/web/acts/CostStory.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; +import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; import { PersonRow } from '../primitives/PersonRow.tsx'; @@ -21,7 +22,7 @@ export const costStoryCopy = { export function CostStory() { const data = useEmbeddedData(); - const { assumptions, derived } = useAssumptions(); + const { assumptions, displayMode, derived } = useAssumptions(); const { humanMergeCount, humanReviewCount } = data.costEstimate; const totalActions = humanMergeCount + humanReviewCount; @@ -41,25 +42,31 @@ 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{' '} - {assumptions.minutesPerPr} minutes per PR, that comes out - to: + {assumptions.minutesPerPr} minutes per PR + {displayMode === 'cost' ? ( + <> + {' '} + and ${assumptions.hourlyRateUsd}/hr + + ) : null} + {', '}that comes out to:

@@ -105,7 +112,7 @@ function fmtHours(hours: number): string { } function PeopleTable({ windowDays }: { windowDays: number }) { - const { assumptions, derived } = useAssumptions(); + const { assumptions, displayMode, derived } = useAssumptions(); const [expanded, setExpanded] = useState(false); const people = combinedPeopleRows(derived.mergers, derived.reviewers); const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT); @@ -130,7 +137,9 @@ function PeopleTable({ windowDays }: { windowDays: number }) {
- + @@ -140,7 +149,13 @@ function PeopleTable({ windowDays }: { windowDays: number }) { login={r.login} mergedCount={r.mergedCount} reviewedCount={r.reviewedCount} - windowHours={Math.round(actionsToHours(r.mergedCount + r.reviewedCount, assumptions.minutesPerPr))} + value={ + displayMode === 'time' + ? Math.round( + actionsToHours(r.mergedCount + r.reviewedCount, assumptions.minutesPerPr), + ).toLocaleString() + : fmtUsd(r.windowCostUsd) + } /> ))} {hiddenCount > 0 || expanded ? ( diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 5b80d23..2c85bec 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -1,26 +1,22 @@ -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'; export const verdictTestIds = { section: 'verdict-section', annualCost: 'verdict-annual-cost', - primaryCta: 'verdict-primary-cta', } as const; export const verdictCopy = { costLeadIn: 'Your engineering team spends', costTrailer: 'triaging, reviewing, and merging Dependabot PRs', - primaryCta: 'See how PatchWave helps', } as const; export function Verdict() { - const { assumptions } = useAssumptions(); + const { assumptions, displayMode, derived } = useAssumptions(); const { costEstimate, prBacklog } = useEmbeddedData(); const { openCount } = prBacklog; - const analytics = useAnalytics(); const totalActions = costEstimate.humanMergeCount + costEstimate.humanReviewCount; const quarterlyHours = actionsToHours(totalActions, assumptions.minutesPerPr); @@ -31,8 +27,10 @@ export function Verdict() { data-testid={verdictTestIds.annualCost} className="text-foreground mt-2 text-5xl leading-none font-medium tracking-tight tabular-nums sm:text-7xl" > - ~{fmtHours(quarterlyHours)} - /quarter + {displayMode === 'time' ? `~${fmtHours(quarterlyHours)}` : `~${fmtUsd(derived.annualCostUsd)}`} + + {displayMode === 'time' ? '/quarter' : '/year'} +

{verdictCopy.costTrailer} @@ -41,16 +39,6 @@ export function Verdict() { )}

- -
diff --git a/src/report/web/assumptionFields.ts b/src/report/web/assumptionFields.ts index 867dcf6..bf41e10 100644 --- a/src/report/web/assumptionFields.ts +++ b/src/report/web/assumptionFields.ts @@ -13,5 +13,6 @@ export interface AssumptionField { } export const assumptionFields = { + hourlyRateUsd: { label: 'Loaded cost of engineering', prefix: '$', suffix: '/hr', min: 1, max: 1000, step: 25 }, minutesPerPr: { label: 'Minutes per PR', min: 1, max: 240, step: 1 }, } as const satisfies Record; diff --git a/src/report/web/hooks/useAssumptions.tsx b/src/report/web/hooks/useAssumptions.tsx index b6bea53..a7a8c33 100644 --- a/src/report/web/hooks/useAssumptions.tsx +++ b/src/report/web/hooks/useAssumptions.tsx @@ -3,7 +3,10 @@ import { deriveCostEstimate, derivePersonCosts } from '../../costFormulas.ts'; import { assumptionFields } from '../assumptionFields.ts'; import type { EmbeddedReportData } from '../types.ts'; +export type SavingsDisplayMode = 'time' | 'cost'; + export interface Assumptions { + hourlyRateUsd: number; minutesPerPr: number; } @@ -20,6 +23,9 @@ type ValueUpdate = number | ((prev: number) => number); interface ContextValue { assumptions: Assumptions; + displayMode: SavingsDisplayMode; + setDisplayMode: (mode: SavingsDisplayMode) => void; + setHourlyRate: (next: ValueUpdate) => void; setMinutesPerPr: (next: ValueUpdate) => void; reset: () => void; derived: DerivedCost; @@ -29,9 +35,23 @@ const Ctx = createContext(null); export function AssumptionsProvider({ data, children }: { data: EmbeddedReportData; children: ReactNode }) { const defaults: Assumptions = { + hourlyRateUsd: data.costEstimate.hourlyRateUsd, minutesPerPr: data.costEstimate.minutesPerPr, }; const [assumptions, setAssumptions] = useState(defaults); + const [displayMode, setDisplayMode] = useState('time'); + const setHourlyRate = useCallback( + (next: ValueUpdate) => + setAssumptions((prev) => ({ + ...prev, + hourlyRateUsd: clamp( + resolve(next, prev.hourlyRateUsd), + assumptionFields.hourlyRateUsd.min, + assumptionFields.hourlyRateUsd.max, + ), + })), + [], + ); const setMinutesPerPr = useCallback( (next: ValueUpdate) => setAssumptions((prev) => ({ @@ -44,14 +64,13 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa })), [], ); - const reset = useCallback(() => setAssumptions(defaults), [defaults.minutesPerPr]); + const reset = useCallback(() => setAssumptions(defaults), [defaults.hourlyRateUsd, defaults.minutesPerPr]); const derived = useMemo(() => { - const { minutesPerPr } = assumptions; - const hourlyRateUsd = data.costEstimate.hourlyRateUsd; + const { hourlyRateUsd, minutesPerPr } = assumptions; const totalActions = data.costEstimate.humanMergeCount + data.costEstimate.humanReviewCount; const windowDays = data.costEstimate.windowDays; - const cost = deriveCostEstimate(totalActions, windowDays, { minutesPerPr, hourlyRateUsd }); + const cost = deriveCostEstimate(totalActions, windowDays, assumptions); return { ...cost, mergers: derivePersonCosts(data.people.mergers, windowDays, minutesPerPr, hourlyRateUsd), @@ -59,7 +78,15 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa }; }, [assumptions, data]); - const value: ContextValue = { assumptions, setMinutesPerPr, reset, derived }; + const value: ContextValue = { + assumptions, + displayMode, + setDisplayMode, + setHourlyRate, + setMinutesPerPr, + reset, + derived, + }; return {children}; } diff --git a/src/report/web/primitives/AssumptionInput.tsx b/src/report/web/primitives/AssumptionInput.tsx index 2078e90..cd29b75 100644 --- a/src/report/web/primitives/AssumptionInput.tsx +++ b/src/report/web/primitives/AssumptionInput.tsx @@ -6,18 +6,57 @@ import { NumberStepper } from './NumberStepper.tsx'; export const assumptionInputTestIds = { container: 'assumption-input-container', + hourlyRateUsd: 'assumption-input-hourly-rate-usd', minutesPerPr: 'assumption-input-minutes-per-pr', + displayTime: 'assumption-input-display-time', + displayCost: 'assumption-input-display-cost', reset: 'assumption-input-reset', } as const; export function AssumptionInput() { - const { assumptions, setMinutesPerPr, reset } = useAssumptions(); + const { assumptions, displayMode, setDisplayMode, setHourlyRate, setMinutesPerPr, reset } = useAssumptions(); const analytics = useAnalytics(); return (
+
+ + Show cost as + +
+ + +
+
analytics.capture('assumption_changed', { field: 'minutes_per_pr', value })} {...assumptionFields.minutesPerPr} /> + {displayMode === 'cost' ? ( + analytics.capture('assumption_changed', { field: 'hourly_rate_usd', value })} + {...assumptionFields.hourlyRateUsd} + /> + ) : null} diff --git a/src/report/web/primitives/HeroAssumptions.tsx b/src/report/web/primitives/HeroAssumptions.tsx index 85832fc..c146af3 100644 --- a/src/report/web/primitives/HeroAssumptions.tsx +++ b/src/report/web/primitives/HeroAssumptions.tsx @@ -1,31 +1,9 @@ -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 it takes{' '} - {assumptions.minutesPerPr} minutes to - review each PR - - - Adjust - Done - - -
- -
-
+
+ +
); } diff --git a/src/report/web/primitives/NumberStepper.tsx b/src/report/web/primitives/NumberStepper.tsx index cac1f3d..480fba7 100644 --- a/src/report/web/primitives/NumberStepper.tsx +++ b/src/report/web/primitives/NumberStepper.tsx @@ -60,9 +60,9 @@ export function NumberStepper({ }; const displayValue = isEditing ? draft : String(value); return ( -
+
{label} -
+
{prefix && ( - + {prefix} )} @@ -101,10 +102,10 @@ export function NumberStepper({ onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} - className={cn('w-28 text-center tabular-nums', prefix && 'pl-7', suffix && 'pr-9')} + className={cn('h-10 w-28 text-center tabular-nums', prefix && 'pl-8', suffix && 'pr-10')} /> {suffix && ( - + {suffix} )} @@ -121,6 +122,7 @@ export function NumberStepper({ onCommit?.(next); setDraft(String(next)); }} + className="size-10" data-testid={incrementTestId} > diff --git a/src/report/web/primitives/PersonRow.tsx b/src/report/web/primitives/PersonRow.tsx index 3f7006a..29d4d82 100644 --- a/src/report/web/primitives/PersonRow.tsx +++ b/src/report/web/primitives/PersonRow.tsx @@ -2,10 +2,10 @@ interface Props { login: string; mergedCount: number; reviewedCount: number; - windowHours: number; + value: string; } -export function PersonRow({ login, mergedCount, reviewedCount, windowHours }: Props) { +export function PersonRow({ login, mergedCount, reviewedCount, value }: Props) { return (
@@ -14,7 +14,7 @@ export function PersonRow({ login, mergedCount, reviewedCount, windowHours }: Pr {mergedCount > 0 && reviewedCount > 0 ? , : null} - + ); }
{login}{windowHours.toLocaleString()}{fmtUsd(windowCostUsd)} - {fmtUsd(annualCostUsd)} -
Person CountTime over last {windowDays} days + {displayMode === 'time' ? `Time over last ${windowDays} days` : `Cost over last ${windowDays} days`} +
{login}{windowHours.toLocaleString()}{value}