From a3dc800354136750d8f1f3ac2c4874342a7edd7f Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 13 Oct 2026 15:01:06 -0400 Subject: [PATCH 01/27] Estimate society-wide run time by region The progress bar divided elapsed time by 6 minutes for every society-wide run. Production runs between 6 and 13 October 2026 took about 64 s (UK nationwide) and 130 s (US state) on average, so their bar showed about 35% when the result arrived. Durations now come from the report's country and region: UK 110 s, US state 190 s, US congressional district 200 s, and US nationwide 6 minutes (unchanged). The strategy returns the estimate on pending statuses, and the synthetic bar uses it without restarting. Co-Authored-By: Claude Opus 5.5 --- app/src/constants/calculationDurations.ts | 55 +++++++++++++-- .../hooks/useAggregatedCalculationStatus.ts | 5 ++ app/src/hooks/useCalculationStatus.ts | 1 + app/src/hooks/useSyntheticProgress.ts | 16 +++-- .../economy/SocietyWideCalcStrategy.ts | 21 ++++-- .../constants/calculationDurations.test.ts | 48 +++++++++++++ .../unit/hooks/useSyntheticProgress.test.tsx | 38 +++++++++++ .../SocietyWideCalcStrategy.test.ts | 67 ++++++++++++++++++- app/src/types/calculation/CalcStatus.ts | 6 ++ 9 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 app/src/tests/unit/constants/calculationDurations.test.ts diff --git a/app/src/constants/calculationDurations.ts b/app/src/constants/calculationDurations.ts index 2d44ea20d..fc2de99d2 100644 --- a/app/src/constants/calculationDurations.ts +++ b/app/src/constants/calculationDurations.ts @@ -11,19 +11,38 @@ * - Queue position * - Server load * - Calculation complexity - * - Dataset size (Enhanced CPS vs standard) + * - Region (a state run is much shorter than a nationwide run) + * + * Society-wide values come from successful production runs between + * 6 and 13 October 2026, measured from submission to completion. Most use + * the 90th percentile, so the bar rarely reaches its cap before the result. */ /** - * Duration estimate for US society-wide calculations - * Used for Enhanced CPS dataset calculations + * Duration estimate for US nationwide society-wide calculations + * Observed mean 338 s, 90th percentile 392 s; 6 minutes sits between them. */ export const US_SOCIETY_WIDE_DURATION_MS = 6 * 60 * 1000; // 6 minutes +/** + * Duration estimate for US state society-wide calculations + * Observed 90th percentile 187 s. Also used for US place (city) runs, + * which have no observed samples yet. + */ +export const US_STATE_SOCIETY_WIDE_DURATION_MS = 190 * 1000; + +/** + * Duration estimate for US congressional district society-wide calculations + * Only one observed sample (200 s), so treat it as a rough value. + */ +export const US_DISTRICT_SOCIETY_WIDE_DURATION_MS = 200 * 1000; + /** * Duration estimate for UK society-wide calculations + * Observed nationwide 90th percentile 108 s. Also used for UK subnational + * runs, which have no observed samples yet. */ -export const UK_SOCIETY_WIDE_DURATION_MS = 3 * 60 * 1000; // 3 minutes +export const UK_SOCIETY_WIDE_DURATION_MS = 110 * 1000; /** * Duration estimate for household calculations @@ -38,7 +57,7 @@ export const HOUSEHOLD_DURATION_MS = 60 * 1000; // 1 minute export const DEFAULT_SOCIETY_WIDE_DURATION_MS = US_SOCIETY_WIDE_DURATION_MS; /** - * Get duration estimate based on country ID + * Get the nationwide duration estimate for a country * @param countryId - Country identifier ('us', 'uk', etc.) * @returns Duration in milliseconds */ @@ -53,18 +72,40 @@ export function getDurationForCountry(countryId: string): number { } } +/** + * Get the duration estimate for a society-wide calculation over a region + * @param countryId - Country identifier ('us', 'uk', etc.) + * @param region - Region as sent to the API, e.g. 'us', 'state/ca' or + * 'congressional_district/CA-46'; omitted or the country code for nationwide + * @returns Duration in milliseconds + */ +export function getDurationForRegion(countryId: string, region?: string): number { + if (countryId.toLowerCase() === 'us') { + const regionType = region?.toLowerCase().split('/')[0]; + if (regionType === 'state' || regionType === 'place') { + return US_STATE_SOCIETY_WIDE_DURATION_MS; + } + if (regionType === 'congressional_district') { + return US_DISTRICT_SOCIETY_WIDE_DURATION_MS; + } + } + return getDurationForCountry(countryId); +} + /** * Get duration estimate based on calculation type * @param calcType - Type of calculation ('household' or 'societyWide') * @param countryId - Optional country identifier for society-wide calculations + * @param region - Optional region for society-wide calculations * @returns Duration in milliseconds */ export function getDurationForCalcType( calcType: 'household' | 'societyWide', - countryId?: string + countryId?: string, + region?: string ): number { if (calcType === 'household') { return HOUSEHOLD_DURATION_MS; } - return countryId ? getDurationForCountry(countryId) : DEFAULT_SOCIETY_WIDE_DURATION_MS; + return countryId ? getDurationForRegion(countryId, region) : DEFAULT_SOCIETY_WIDE_DURATION_MS; } diff --git a/app/src/hooks/useAggregatedCalculationStatus.ts b/app/src/hooks/useAggregatedCalculationStatus.ts index a84f7fb17..297c37746 100644 --- a/app/src/hooks/useAggregatedCalculationStatus.ts +++ b/app/src/hooks/useAggregatedCalculationStatus.ts @@ -246,8 +246,13 @@ export function useAggregatedCalculationStatus( anyLoading || calculations.some((calc) => calc.status === 'pending'); // Generate synthetic progress + // The longest pending estimate, so the bar does not finish before the slowest run + const pendingDurations = calculations + .filter((calc) => calc.status === 'pending' && calc.estimatedDurationMs !== undefined) + .map((calc) => calc.estimatedDurationMs as number); const synthetic = useSyntheticProgress(needsSyntheticProgress, calcType, { queuePosition: calculations.find((calc) => calc.queuePosition)?.queuePosition, + estimatedDurationMs: pendingDurations.length > 0 ? Math.max(...pendingDurations) : undefined, }); // Check if any are initializing diff --git a/app/src/hooks/useCalculationStatus.ts b/app/src/hooks/useCalculationStatus.ts index 58ee5c38e..f24b6aadb 100644 --- a/app/src/hooks/useCalculationStatus.ts +++ b/app/src/hooks/useCalculationStatus.ts @@ -100,6 +100,7 @@ function useSingleCalculationStatus(calcId: string, targetType: 'report' | 'simu // Generate synthetic progress const synthetic = useSyntheticProgress(needsSyntheticProgress, calcType, { queuePosition: status?.queuePosition, + estimatedDurationMs: status?.estimatedDurationMs, }); const currentStatus = status?.status || 'initializing'; diff --git a/app/src/hooks/useSyntheticProgress.ts b/app/src/hooks/useSyntheticProgress.ts index bc5bf1436..713fe923e 100644 --- a/app/src/hooks/useSyntheticProgress.ts +++ b/app/src/hooks/useSyntheticProgress.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { HOUSEHOLD_DURATION_MS, US_SOCIETY_WIDE_DURATION_MS, @@ -10,6 +10,8 @@ import { interface ServerProgress { queuePosition?: number; estimatedTimeRemaining?: number; + /** Typical society-wide duration for this run; replaces the default when set */ + estimatedDurationMs?: number; } /** @@ -116,6 +118,10 @@ export function useSyntheticProgress( progress: 0, message: '', }); + // Read through a ref: the estimate arrives with the first server status, + // and a dependency on it would restart the timer from zero. + const estimatedDurationRef = useRef(serverProgress?.estimatedDurationMs); + estimatedDurationRef.current = serverProgress?.estimatedDurationMs; useEffect(() => { if (!isActive) { @@ -124,13 +130,13 @@ export function useSyntheticProgress( } const startTime = Date.now(); - const estimatedDuration = - calcType === 'household' - ? SYNTHETIC_PROGRESS_CONFIG.HOUSEHOLD_DURATION_MS - : SYNTHETIC_PROGRESS_CONFIG.SOCIETY_WIDE_DURATION_MS; const interval = setInterval(() => { const elapsed = Date.now() - startTime; + const estimatedDuration = + calcType === 'household' + ? SYNTHETIC_PROGRESS_CONFIG.HOUSEHOLD_DURATION_MS + : (estimatedDurationRef.current ?? SYNTHETIC_PROGRESS_CONFIG.SOCIETY_WIDE_DURATION_MS); // Pure time-based synthetic progress (for both household and society-wide) // This provides consistent linear progress that users expect diff --git a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts index 643eb0946..f4e663c78 100644 --- a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts +++ b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts @@ -4,7 +4,7 @@ import { SocietyWideCalculationParams, SocietyWideCalculationResponse, } from '@/api/societyWideCalculation'; -import { getDurationForCountry } from '@/constants/calculationDurations'; +import { getDurationForRegion } from '@/constants/calculationDurations'; import { CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; import { CalcExecutionStrategy, RefetchConfig } from '../strategies/types'; @@ -40,7 +40,12 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { ); // Transform to unified status with provided metadata - return this.transformResponseWithMetadata(response, metadata, params.countryId); + return this.transformResponseWithMetadata( + response, + metadata, + params.countryId, + params.region + ); } catch (error) { console.error('[SocietyWideCalcStrategy.execute] Calculation failed:', error); const errorMessage = @@ -79,7 +84,8 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { transformResponseWithMetadata( apiResponse: unknown, metadata: CalcMetadata, - countryId?: string + countryId?: string, + region?: string ): CalcStatus { const response = apiResponse as SocietyWideCalculationResponse; @@ -87,12 +93,13 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { if (response.status === 'computing') { // Calculate synthetic progress based on elapsed time (mimicking household calculations) const elapsed = Date.now() - metadata.startedAt; - const estimatedDuration = this.getEstimatedDuration(countryId); + const estimatedDuration = this.getEstimatedDuration(countryId, region); const progress = Math.min((elapsed / estimatedDuration) * 100, 95); // Cap at 95% return { status: 'pending', progress, + estimatedDurationMs: estimatedDuration, queuePosition: response.queue_position, message: this.getComputingMessage(response.queue_position), metadata, @@ -144,10 +151,10 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { } /** - * Get estimated duration for society-wide calculations based on country + * Get estimated duration for society-wide calculations based on country and region * Uses shared constants from calculationDurations */ - private getEstimatedDuration(countryId?: string): number { - return getDurationForCountry(countryId || 'us'); + private getEstimatedDuration(countryId?: string, region?: string): number { + return getDurationForRegion(countryId || 'us', region); } } diff --git a/app/src/tests/unit/constants/calculationDurations.test.ts b/app/src/tests/unit/constants/calculationDurations.test.ts new file mode 100644 index 000000000..79cae4346 --- /dev/null +++ b/app/src/tests/unit/constants/calculationDurations.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + getDurationForCalcType, + getDurationForCountry, + getDurationForRegion, + HOUSEHOLD_DURATION_MS, + UK_SOCIETY_WIDE_DURATION_MS, + US_DISTRICT_SOCIETY_WIDE_DURATION_MS, + US_SOCIETY_WIDE_DURATION_MS, + US_STATE_SOCIETY_WIDE_DURATION_MS, +} from '@/constants/calculationDurations'; + +describe('calculationDurations', () => { + describe('getDurationForRegion', () => { + it.each([ + ['us', undefined, US_SOCIETY_WIDE_DURATION_MS], + ['us', 'us', US_SOCIETY_WIDE_DURATION_MS], + ['us', 'state/ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'place/CA-57000', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'congressional_district/CA-46', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ['uk', 'uk', UK_SOCIETY_WIDE_DURATION_MS], + ['uk', 'constituency/Sheffield Central', UK_SOCIETY_WIDE_DURATION_MS], + ['uk', 'country/england', UK_SOCIETY_WIDE_DURATION_MS], + ])('given %s and region %s then returns %i ms', (countryId, region, expected) => { + expect(getDurationForRegion(countryId, region)).toBe(expected); + }); + + it('given a US state then the estimate is shorter than a nationwide run', () => { + expect(getDurationForRegion('us', 'state/ny')).toBeLessThan(getDurationForRegion('us')); + }); + + it('given an unknown country then falls back to the country default', () => { + expect(getDurationForRegion('ca', 'state/ca')).toBe(getDurationForCountry('ca')); + }); + }); + + describe('getDurationForCalcType', () => { + it('given household then returns the household duration', () => { + expect(getDurationForCalcType('household', 'us', 'state/ca')).toBe(HOUSEHOLD_DURATION_MS); + }); + + it('given society-wide with a region then returns the region duration', () => { + expect(getDurationForCalcType('societyWide', 'us', 'state/ca')).toBe( + US_STATE_SOCIETY_WIDE_DURATION_MS + ); + }); + }); +}); diff --git a/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx b/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx index aa9b9e23e..818e2ef1a 100644 --- a/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx +++ b/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx @@ -217,6 +217,44 @@ describe('useSyntheticProgress', () => { ); }); + it('given a region duration estimate then progresses against it', () => { + // Given + const { result } = renderHook(() => + useSyntheticProgress(true, 'societyWide', { estimatedDurationMs: 110000 }) + ); + + // When - advance 55 seconds (half of 110 seconds) + act(() => { + vi.advanceTimersByTime(55000); + }); + + // Then + expect(result.current.progress).toBeGreaterThanOrEqual(49); + expect(result.current.progress).toBeLessThanOrEqual(51); + }); + + it('given the estimate arrives after start then the bar does not restart', () => { + // Given - no estimate yet, so the 6 minute default applies + const { result, rerender } = renderHook( + ({ estimate }: { estimate?: number }) => + useSyntheticProgress(true, 'societyWide', { estimatedDurationMs: estimate }), + { initialProps: { estimate: undefined as number | undefined } } + ); + act(() => { + vi.advanceTimersByTime(60000); + }); + + // When - the first status brings a 120 second estimate + rerender({ estimate: 120000 }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + // Then - 61 of 120 seconds have passed since the start, not 1 + expect(result.current.progress).toBeGreaterThanOrEqual(50); + expect(result.current.progress).toBeLessThanOrEqual(52); + }); + it('given full duration then caps at max progress', () => { // Given const { result } = renderHook(() => useSyntheticProgress(true, 'societyWide')); diff --git a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts index 4ffcdf1e7..2f4579885 100644 --- a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts @@ -73,6 +73,7 @@ describe('SocietyWideCalcStrategy', () => { // Then expect(result.status).toBe('pending'); expect(result.queuePosition).toBe(STRATEGY_TEST_CONSTANTS.TEST_QUEUE_POSITION); + expect(result.estimatedDurationMs).toBe(6 * 60 * 1000); expect(result.message).toContain(`position ${STRATEGY_TEST_CONSTANTS.TEST_QUEUE_POSITION}`); }); @@ -223,6 +224,26 @@ describe('SocietyWideCalcStrategy', () => { }); }); + describe('execute with a region', () => { + it('given a congressional district then estimates from the district duration', async () => { + // Given + const params = mockSocietyWideCalcParams({ region: 'congressional_district/CA-46' }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, { + calcId: 'test', + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }); + + // Then + expect(result.status).toBe('pending'); + expect(result.estimatedDurationMs).toBe(200000); + }); + }); + describe('getRefetchConfig', () => { it('given config requested then returns valid refetch configuration', () => { // When @@ -341,7 +362,7 @@ describe('SocietyWideCalcStrategy', () => { calcId: 'test-report', calcType: 'societyWide' as const, targetType: 'report' as const, - startedAt: Date.now() - 90000, // Started 1.5 minutes ago + startedAt: Date.now() - 55000, // Started 55 seconds ago }; // When @@ -350,8 +371,50 @@ describe('SocietyWideCalcStrategy', () => { // Then expect(result.status).toBe('pending'); expect(result.progress).toBeDefined(); - // 1.5 minutes elapsed / 3 minute estimate = 50% + // 55 seconds elapsed / 110 second estimate = 50% + expect(result.progress).toBeCloseTo(50, 0); + expect(result.estimatedDurationMs).toBe(110000); + }); + + it('given computing response for a US state then uses the state duration estimate', () => { + // Given + const apiResponse = mockSocietyWideComputingResponse(); + const metadata = { + calcId: 'test-report', + calcType: 'societyWide' as const, + targetType: 'report' as const, + startedAt: Date.now() - 95000, // Started 95 seconds ago + }; + + // When + const result = strategy.transformResponseWithMetadata( + apiResponse, + metadata, + 'us', + 'state/ca' + ); + + // Then + // 95 seconds elapsed / 190 second estimate = 50% expect(result.progress).toBeCloseTo(50, 0); + expect(result.estimatedDurationMs).toBe(190000); + }); + + it('given computing response for a US nationwide run then keeps the 6 minute estimate', () => { + // Given + const apiResponse = mockSocietyWideComputingResponse(); + const metadata = { + calcId: 'test-report', + calcType: 'societyWide' as const, + targetType: 'report' as const, + startedAt: Date.now(), + }; + + // When + const result = strategy.transformResponseWithMetadata(apiResponse, metadata, 'us', 'us'); + + // Then + expect(result.estimatedDurationMs).toBe(6 * 60 * 1000); }); it('given computing response with unknown country then uses default duration', () => { diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index 1f30dcb39..e9e5cefbd 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -87,6 +87,12 @@ export interface CalcStatus { */ queuePosition?: number; + /** + * Typical duration of this calculation in milliseconds, for time-based + * progress. Set for pending society-wide calculations from their region. + */ + estimatedDurationMs?: number; + /** * The calculation result (only present when status is 'complete') */ From 6368204b42a141f84df217ce5de895e47032b8de Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:52:50 -0400 Subject: [PATCH 02/27] Validation: the same checks on every report, in one card style Every v3 report, custom or bill, now opens Validation on three cards: external estimates (the bill's fiscal note, or related published scores, or one line that none exists), data calibration (official totals against PolicyEngine in one aligned table), and baseline comparisons (the scorecard's published measures). Numbers are right-aligned in tabular figures; each row opens its details. The fiscal-note range chip stays in Validation. Co-Authored-By: Claude Opus 5.5 --- .../flagship/CalibrationMatches.tsx | 389 ++++++--------- .../flagship/ScorecardComparisons.tsx | 469 ++++++++---------- .../components/flagship/ValidationDisplay.tsx | 249 +++++++++- .../components/flagship/ValidationPanel.tsx | 435 +++++++--------- .../flagship/report/ReportValidation.tsx | 106 ++++ .../components/flagship/report/ReportView.tsx | 60 +-- app/src/pages/flagship/BillReport.page.tsx | 16 +- .../flagship/CalibrationMatches.test.tsx | 36 +- .../flagship/ScorecardComparisons.test.tsx | 170 ++++--- .../flagship/ValidationPanel.test.tsx | 191 ++++--- .../flagship/report/ReportValidation.test.tsx | 155 ++++++ .../flagship/report/ReportView.test.tsx | 78 ++- .../unit/pages/flagship/BillReport.test.tsx | 34 +- .../tests/unit/pages/flagship/Report.test.tsx | 37 +- 14 files changed, 1446 insertions(+), 979 deletions(-) create mode 100644 app/src/components/flagship/report/ReportValidation.tsx create mode 100644 app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index ccbd96cbc..acb1ffe43 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -1,30 +1,39 @@ import { useEffect, useState } from 'react'; -import { Spinner, Stack, Text } from '@/components/ui'; +import { Text } from '@/components/ui'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { colors, spacing, typography } from '@/designTokens'; import { CalibrationMatches, calibrationMatchesForPaths, CalibrationRing, + CalibrationTargetRow, dashboardTargetsUrl, geographyForRegion, } from '@/libs/flagship/calibrationMatching'; import type { ReportValidationSnapshot } from '@/libs/flagship/reportValidation'; import { - cardStyle, + CellNote, detailStyle, - EvidenceHeader, + EvidenceTable, + labelCellStyle, linkStyle, - panelStyle, + numberCellStyle, + percentText, + rowButtonStyle, + rowStyle, summaryStyle, + ValidationCard, + ValidationLoading, + ValidationNote, } from './ValidationDisplay'; /** * The data-side validation surface for a report: which of the variables * this reform moves are calibration targets of the microdata, and how - * well the current populace release fits them. Nearest variables first; - * the interacted quantity within the mechanism is the one that bounds - * the data-side bias of the estimate. + * well the current populace release fits them — the model's baseline + * totals against the official ones. Nearest variables first; the + * interacted quantity within the mechanism is the one that bounds the + * data-side bias of the estimate. */ export const RING_LABELS: Record = { @@ -44,10 +53,7 @@ export function describeDepth(depth: number): string { /** Above this the fit is worth calling out; the diagnostics skill's escalation bar. */ export const ATTENTION_ERROR = 0.25; -function percent(fraction: number): string { - const value = fraction * 100; - return `${Math.abs(value) >= 100 ? Math.round(value) : value.toFixed(1)}%`; -} +const CALIBRATION_COLUMNS = ['Target', 'Period', 'Official', 'PolicyEngine', 'Gap']; function humanize(variable: string): string { const labels: Record = { @@ -60,32 +66,18 @@ function humanize(variable: string): string { return label.charAt(0).toUpperCase() + label.slice(1); } -function SectionCard({ children }: { children: React.ReactNode }) { - return {children}; +function compactNumber(value: number | null): string { + return value === null + ? '—' + : value.toLocaleString('en-US', { notation: 'compact', maximumFractionDigits: 1 }); } -function DashboardLink({ - children, - source, - level, - title, -}: { - children: React.ReactNode; - source?: string | null; - level?: 'national' | 'state' | null; - title?: string; -}) { - return ( - - {children} - - ); +function exactNumber(value: number | null): string { + return value?.toLocaleString('en-US', { maximumFractionDigits: 2 }) ?? '—'; +} + +function gapOf(target: CalibrationTargetRow): string { + return target.relativeError === null ? '—' : percentText(target.relativeError); } /** @@ -137,11 +129,66 @@ function PinNote({ pin }: { pin: CalibrationPin }) { {pin.drift.length === 0 ? `Pinned ${when}; the release and model it was matched against are still current.` - : `Pinned ${when}; since then ${pin.drift.join(' and ')}. The cards show the live comparison; the pinned one is historical.`} + : `Pinned ${when}; since then ${pin.drift.join(' and ')}. The table shows the live comparison; the pinned one is historical.`} ); } +/** A target's name, opening its exact values. */ +function TargetDetails({ target }: { target: CalibrationTargetRow }) { + return ( + + + + + + Calibration target + {target.name} +
+ {[ + ['Source', target.sourceLabel ?? target.source], + ['Geography', target.geography], + ['Period', target.period ?? '—'], + ['Measure', target.measure ?? '—'], + ['Official', exactNumber(target.target)], + ['PolicyEngine', exactNumber(target.estimate)], + ['Gap', gapOf(target)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ {target.sourceUrl && ( + + Read source + + )} + + View target group in dashboard + +
+
+ ); +} + export function CalibrationMatchSection({ matches, pin, @@ -149,219 +196,93 @@ export function CalibrationMatchSection({ matches: CalibrationMatches | null | undefined; pin?: CalibrationPin; }) { + const level = matches ? (matches.geography === 'US' ? 'national' : 'state') : null; + const card = (content: React.ReactNode) => ( + + {content} + + ); if (matches === null) { - return ( - - - Calibration checks are temporarily unavailable — see the{' '} - calibration dashboard directly. - - - ); + return card(Calibration checks are temporarily unavailable.); } if (matches === undefined) { - return ( - - - - - Checking the calibration of the variables this reform moves… - - - - ); + return card(Checking calibration targets…); } - const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`; - const level = matches.geography === 'US' ? 'national' : 'state'; if (matches.matches.length === 0) { - if (matches.reachedCount === 0) { - return null; - } + const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`; // Silence would read as "fine". State credits are the usual case: the // dashboard's state income tax targets carry no model-variable mapping. - return ( - - - None of the {matches.reachedCount} variables this reform moves is a calibration target{' '} - {where}, so the data behind this estimate is unvalidated on that side. See the{' '} - calibration dashboard for what is covered. - - + return card( + + {matches.reachedCount > 0 + ? `None of the ${matches.reachedCount} variables this reform moves is a calibration target ${where}.` + : 'No calibration target covers this reform.'} + ); } - return ( - - -
- {matches.matches.map((match) => ( - - -
- - - - {['Target', 'Period', 'Target value', 'Model value', 'Gap'].map((label) => ( - - ))} - - - - {match.targets.map((target) => ( - - - - - - - - ))} - -
- {label} -
- - - - - - Calibration target - - {target.name} - -
- {[ - ['Source', target.sourceLabel ?? target.source], - ['Geography', target.geography], - ['Period', target.period ?? '—'], - ['Measure', target.measure ?? '—'], - [ - 'Target value', - target.target?.toLocaleString('en-US', { - maximumFractionDigits: 2, - }) ?? '—', - ], - [ - 'Model value', - target.estimate?.toLocaleString('en-US', { - maximumFractionDigits: 2, - }) ?? '—', - ], - [ - 'Gap', - target.relativeError === null - ? '—' - : percent(target.relativeError), - ], - ].map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
- {target.sourceUrl && ( - - Read source - - )} - - View target group in dashboard - -
-
- - {target.sourceLabel ?? target.source} · {target.geography} - {target.measure ? ` · ${target.measure}` : ''} - -
- {target.period ?? '—'} - - {target.target === null - ? '—' - : target.target.toLocaleString('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - })} - - {target.estimate === null - ? '—' - : target.estimate.toLocaleString('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - })} - - {target.relativeError === null ? '—' : percent(target.relativeError)} -
-
-
- ))} -
+ return card( + <> + + {matches.matches.map((match) => { + const sources = [ + ...new Set(match.targets.map((target) => target.sourceLabel ?? target.source)), + ].join(', '); + return ( + + + + + {humanize(match.variable)} + + + + {sources} · {matches.geography} + {' '} + ·{' '} + + View targets + + + + + {match.targets.map((target) => ( + + + + + {target.period ?? '—'} + {compactNumber(target.target)} + {compactNumber(target.estimate)} + {gapOf(target)} + + ))} + + ); + })} +
Data release - {matches.releaseId ? `Data release ${matches.releaseId}` : 'Release not recorded'} - {matches.modelVersion ? ` · Matched at policyengine-us ${matches.modelVersion}` : ''}. + {matches.releaseId ?? 'Not recorded'} + {matches.modelVersion ? ` · policyengine-us ${matches.modelVersion}` : ''}
{pin && } -
+ ); } diff --git a/app/src/components/flagship/ScorecardComparisons.tsx b/app/src/components/flagship/ScorecardComparisons.tsx index 4a26c8e91..8be7bdfb7 100644 --- a/app/src/components/flagship/ScorecardComparisons.tsx +++ b/app/src/components/flagship/ScorecardComparisons.tsx @@ -1,9 +1,8 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; import { Stack, Text } from '@/components/ui'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { colors, spacing, typography } from '@/designTokens'; -import { SCORECARD_URL } from '@/libs/flagship/modelValidation'; import { loadParameterDependencies, variablesReachedByPaths, @@ -16,12 +15,20 @@ import { } from '@/libs/flagship/scorecardComparisons'; import { cardStyle, + CellNote, detailStyle, EvidenceHeader, + EvidenceTable, EvidenceValues, + gapText, + labelCellStyle, linkStyle, - panelStyle, + numberCellStyle, + rowButtonStyle, + rowStyle, summaryStyle, + ValidationNote, + ValidationSubheading, } from './ValidationDisplay'; const matchLabels = { @@ -301,132 +308,47 @@ const baselineDescriptions: Record = { ], }; -export function ScorecardClaimCard({ - row, - presentation, - showDetails = false, -}: { - showDetails?: boolean; +/** How a claim reads in its row and dialog, when the caller words it better than the claim. */ +export interface ClaimPresentation { + title: string; + sourceName?: string; + externalPeriod?: string; + modelPeriod?: string; + externalValue?: string; + peValue?: string; +} + +/** A scorecard claim, and how to present it. */ +export interface ScorecardClaimEntry { row: RelatedScorecardClaim; - presentation?: { - title: string; - sourceName?: string; - externalPeriod?: string; - modelPeriod?: string; - externalValue?: string; - peValue?: string; - }; -}) { - const latest = row.latest; - const status = latest?.status_effective ?? latest?.status ?? 'not_computed'; + presentation?: ClaimPresentation; +} + +function claimTitle(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string { + return ( + presentation?.title ?? + (isJctCtc(row) ? 'CTC: $1,000 → $2,200, plus other rule changes' : displayTitle(row.name)) + ); +} + +/** The source, and the geography when the claim records one: "Joint Committee on Taxation · US". */ +function claimWhere(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string { const sourceName = presentation?.sourceName ?? sourceNames[row.source] ?? human(row.source).toUpperCase(); + return [sourceName, row.geography && human(row.geography)].filter(Boolean).join(' · '); +} + +/** Everything the scorecard records about one claim, for its detail dialog. */ +export function ScorecardClaimDetails({ row, presentation }: ScorecardClaimEntry) { + const latest = row.latest; + const status = latest?.status_effective ?? latest?.status ?? 'not_computed'; const sourceUrl = safeUrl(row.url); - if (!showDetails) { - const title = - presentation?.title ?? - (isJctCtc(row) ? 'CTC: $1,000 → $2,200, plus other rule changes' : displayTitle(row.name)); - const published = - presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept); - const modeled = presentation?.peValue ?? claimValue(latest?.value, row.unit_concept); - const gap = - typeof latest?.value === 'number' && - typeof row.external_value === 'number' && - row.external_value !== 0 - ? `${(((latest.value - row.external_value) / Math.abs(row.external_value)) * 100).toFixed(1)}%` - : '—'; - return ( -
- - - - {['Comparison', 'Published estimate', 'PolicyEngine estimate', 'Gap'].map((label) => ( - - ))} - - - - - - - - - - -
- {label} -
- - - - - - - {row.reform_framework === 'baseline' - ? 'Baseline comparison' - : 'Related reform comparison'} - - - - - - {sourceName} · {human(row.geography)} - - {sourceUrl && ( - - Read source - - )} - - {published} - - {presentation?.externalPeriod ?? claimPeriod(row)} - - - {modeled} - - {presentation?.modelPeriod ?? modelPeriod(row)} - - - {gap} -
-
- ); - } return (
@@ -552,172 +474,187 @@ export function ScorecardClaimCard({ ); } -export function ScorecardComparisonResults({ - result, - baselineContent, - reportContext, - feedStatus, +/** One claim as a table row: what is compared, both values, and the gap. */ +function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) { + const sourceUrl = safeUrl(row.url); + return ( + + + + + + + + + {row.reform_framework === 'baseline' + ? 'Baseline comparison' + : 'Related reform comparison'} + + + + + {claimWhere(row, presentation)} + {sourceUrl && ( + + Read source + + )} + + + {presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept)} + {presentation?.externalPeriod ?? claimPeriod(row)} + + + {presentation?.peValue ?? claimValue(row.latest?.value, row.unit_concept)} + {presentation?.modelPeriod ?? modelPeriod(row)} + + {gapText(row.latest?.value, row.external_value)} + + ); +} + +/** Scorecard claims in one table, so their numbers line up. Each title opens its details. */ +export function ScorecardClaimTable({ + label, + entries, }: { - result: ScorecardComparisonsResult; - baselineContent?: ReactNode; - reportContext?: ReactNode; - feedStatus?: string; + label: string; + entries: ScorecardClaimEntry[]; }) { - const baseline = result.rows.filter((row) => row.reform_framework === 'baseline'); - const reforms = result.rows.filter((row) => row.reform_framework !== 'baseline'); return ( - - {[ - { - title: 'Baseline comparisons', - description: 'How the model compares with published measures of existing programs.', - rows: baseline, - }, - { - title: 'Related reform comparisons', - description: - 'How PolicyEngine scored other published policy changes. These are not a validation of this report’s exact reform or cost.', - rows: reforms, - }, - ].map(({ title, description, rows }) => ( - -
- - {title} - - - View scorecard -
- {title !== 'Baseline comparisons' && ( - {description} - )} - {title === 'Related reform comparisons' && reportContext} - {title === 'Baseline comparisons' && baselineContent} - {feedStatus && {feedStatus}} - {rows.length ? ( -
- {rows.map((row) => ( - - ))} -
- ) : feedStatus || (title === 'Baseline comparisons' && baselineContent) ? null : ( - - No linked {title === 'Related reform comparisons' ? 'reform' : 'baseline'} comparisons - were found in the published scorecard feed. - - )} -
+ + {entries.map((entry) => ( + ))} -
- Coverage and last update - - Coverage is limited to published claims with a recorded variable, program, parameter path - or shared source line item.{' '} - {result.unmappedCount > 0 - ? `${result.unmappedCount} claims in this country have no usable mapping and were not matched. ` - : ''} - Absence of a match does not establish that no external estimate exists. - {result.built ? ` Scorecard export: ${result.built}.` : ''} - -
+
+ ); +} + +/** Published scorecard estimates of other policies that change the same rules. */ +export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[] }) { + return ( + + Related reforms + + Published scores of policies that change the same rules. They do not score this exact + reform. + + ({ row }))} /> ); } -export default function ScorecardComparisons({ - countryId, - paths, - baselineContent, - reportContext, -}: { - countryId: string; - paths: string[]; - baselineContent?: ReactNode; - reportContext?: ReactNode; -}) { +/** What the scorecard match covers, collapsed. */ +export function ScorecardCoverage({ result }: { result: ScorecardComparisonsResult }) { + return ( +
+ Coverage + + Matches cover scorecard claims with a recorded variable, program, parameter path or source + line item. + {result.unmappedCount > 0 + ? ` ${result.unmappedCount} claims in this country have no usable mapping.` + : ''} + {result.built ? ` Scorecard export: ${result.built}.` : ''} + +
+ ); +} + +/** + * The published scorecard claims linked to a reform: `undefined` while + * loading, `null` when the feed is unavailable. + */ +export type ScorecardComparisonsState = ScorecardComparisonsResult | null | undefined; + +const NO_COMPARISONS: ScorecardComparisonsResult = { built: null, rows: [], unmappedCount: 0 }; + +/** The claims that score a policy change, apart from those that measure today's programs. */ +export function splitScorecardRows(result: ScorecardComparisonsState): { + reforms: RelatedScorecardClaim[]; + baseline: RelatedScorecardClaim[]; +} { + const rows = result?.rows ?? []; + return { + reforms: rows.filter((row) => row.reform_framework !== 'baseline'), + baseline: rows.filter((row) => row.reform_framework === 'baseline'), + }; +} + +async function fetchScorecardComparisons( + countryId: string, + paths: string[], + signal: AbortSignal +): Promise { + let variables: string[] = []; + // The committed dependency map is US-only. Never use it for another country. + if (countryId === 'us') { + try { + variables = variablesReachedByPaths(paths, await loadParameterDependencies()).map( + (entry) => entry.variable + ); + } catch { + /* Recorded parameter paths still allow conservative matching. */ + } + } + const response = await fetch('/api/scorecard-comparisons', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ country: countryId, paths, variables }), + }); + if (!response.ok) { + throw new Error('Scorecard unavailable'); + } + return (await response.json()) as ScorecardComparisonsResult; +} + +/** Fetches the published scorecard claims linked to a reform's parameter paths. */ +export function useScorecardComparisons( + countryId: string, + paths: string[] +): ScorecardComparisonsState { const requestKey = JSON.stringify({ countryId, paths }); const [state, setState] = useState<{ key: string; result: ScorecardComparisonsResult | null }>(); useEffect(() => { - let cancelled = false; - const controller = new AbortController(); const request = JSON.parse(requestKey) as { countryId: string; paths: string[] }; if (!request.paths.length) { - return () => controller.abort(); + return; } - (async () => { - let variables: string[] = []; - // The committed dependency map is US-only. Never use it for another country. - if (request.countryId === 'us') { - try { - variables = variablesReachedByPaths(request.paths, await loadParameterDependencies()).map( - (entry) => entry.variable - ); - } catch { - /* Recorded parameter paths still allow conservative matching. */ + let cancelled = false; + const controller = new AbortController(); + fetchScorecardComparisons(request.countryId, request.paths, controller.signal) + .then((result) => { + if (!cancelled) { + setState({ key: requestKey, result }); + } + }) + .catch(() => { + if (!cancelled) { + setState({ key: requestKey, result: null }); } - } - const response = await fetch('/api/scorecard-comparisons', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ country: request.countryId, paths: request.paths, variables }), }); - if (!response.ok) { - throw new Error('Scorecard unavailable'); - } - const result = (await response.json()) as ScorecardComparisonsResult; - if (!cancelled) { - setState({ key: requestKey, result }); - } - })().catch(() => { - if (!cancelled) { - setState({ key: requestKey, result: null }); - } - }); return () => { cancelled = true; controller.abort(); }; }, [requestKey]); if (!paths.length) { - return No parameter changes are available to match to scorecard comparisons.; + return NO_COMPARISONS; } - const pending = state?.key !== requestKey; - return ( - - ); + return state?.key === requestKey ? state.result : undefined; } diff --git a/app/src/components/flagship/ValidationDisplay.tsx b/app/src/components/flagship/ValidationDisplay.tsx index 9e0598a92..1ad7d597b 100644 --- a/app/src/components/flagship/ValidationDisplay.tsx +++ b/app/src/components/flagship/ValidationDisplay.tsx @@ -1,15 +1,14 @@ -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; -import { Text } from '@/components/ui'; +import { Spinner, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -export const panelStyle = { - padding: spacing.xl, - border: `1px solid ${colors.border.light}`, - borderRadius: spacing.radius.feature, - background: colors.background.primary, - gap: spacing.lg, -}; +/** + * The validation tab's building blocks: one card frame for every check, + * the same as the overview's sections, and one table style so numbers + * line up in tabular figures across every comparison. + */ + export const cardStyle = { gap: spacing.sm, padding: spacing.md, @@ -39,6 +38,237 @@ export const summaryStyle = { paddingBlock: spacing.sm, }; +/** A link-styled button that opens a row's detail dialog. */ +export const rowButtonStyle: CSSProperties = { + ...linkStyle, + display: 'inline', + background: 'none', + border: 0, + padding: 0, + font: 'inherit', + fontWeight: typography.fontWeight.medium, + textAlign: 'left', + overflowWrap: 'anywhere', + cursor: 'pointer', +}; + +/** One validation check, framed like the overview's section cards. */ +export function ValidationCard({ + id, + title, + href, + linkLabel, + children, +}: { + /** The heading's id, which names the card for assistive technology. */ + id: string; + title: string; + href?: string; + linkLabel?: string; + children: ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {href && linkLabel && ( + + {linkLabel} + + )} +
+ {children} +
+ ); +} + +/** A heading for a group of rows inside a card. */ +export function ValidationSubheading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** A plain line of text in a card: an empty state, a caveat, a provenance note. */ +export function ValidationNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** A card's check is still loading; the line, not the spinner, is what gets announced. */ +export function ValidationLoading({ children }: { children: ReactNode }) { + return ( + + + ); +} + +const headerCellStyle: CSSProperties = { + padding: `0 0 ${spacing.sm} ${spacing.lg}`, + textAlign: 'right', + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.text.secondary, + whiteSpace: 'nowrap', +}; + +export const rowStyle: CSSProperties = { borderTop: `1px solid ${colors.border.light}` }; + +/** The first cell of a row: what is compared. */ +export const labelCellStyle: CSSProperties = { + padding: `${spacing.md} ${spacing.lg} ${spacing.md} 0`, + textAlign: 'left', + verticalAlign: 'top', + fontWeight: typography.fontWeight.normal, + minWidth: 160, +}; + +/** A number, right-aligned in tabular figures so columns line up. */ +export const numberCellStyle: CSSProperties = { + padding: `${spacing.md} 0 ${spacing.md} ${spacing.lg}`, + textAlign: 'right', + verticalAlign: 'top', + whiteSpace: 'nowrap', + fontVariantNumeric: 'tabular-nums', +}; + +/** A secondary line under a cell's main text, e.g. a source or a period. */ +export function CellNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * A comparison table: a label column, then right-aligned number columns. + * Children are rows; with `grouped`, they are one `` per group. + */ +export function EvidenceTable({ + label, + columns, + grouped = false, + children, +}: { + label: string; + columns: string[]; + grouped?: boolean; + children: ReactNode; +}) { + return ( +
+ + + + {columns.map((column, index) => ( + + ))} + + + {grouped ? children : {children}} +
+ {column} +
+
+ ); +} + +/** + * How far PolicyEngine's number sits from another, as a share of the + * other: (model − other) ÷ |other|. A dash when either is missing. + */ +export function gapText(model: number | null | undefined, other: number | null | undefined) { + if (typeof model !== 'number' || typeof other !== 'number' || other === 0) { + return '—'; + } + return percentText((model - other) / Math.abs(other)); +} + +/** A fraction as a percent: one decimal, whole numbers from 100%. */ +export function percentText(fraction: number): string { + const value = fraction * 100; + return `${Math.abs(value) >= 100 ? Math.round(value) : value.toFixed(1)}%`; +} + export function EvidenceHeader({ title, subtitle, @@ -110,6 +340,7 @@ export function EvidenceValues({ style={{ fontSize: typography.fontSize.lg, fontWeight: typography.fontWeight.semibold, + fontVariantNumeric: 'tabular-nums', margin: 0, }} > diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index ea0d61788..dd0bd9fc7 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { BillValidation } from '@/api/billFeed'; import { describeDepth, RING_LABELS } from '@/components/flagship/CalibrationMatches'; -import { Spinner, Stack, Text } from '@/components/ui'; +import { Stack } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { claimsFromBillValidation, @@ -9,18 +9,31 @@ import { ModelValidationRow, PROGRAM_LABELS, SCORECARD_COMPARISON_URL, - SCORECARD_URL, ScorecardMatches, scorecardMatchesForPaths, } from '@/libs/flagship/modelValidation'; -import { ScorecardClaimCard } from './ScorecardComparisons'; +import type { RelatedScorecardClaim } from '@/libs/flagship/scorecardComparisons'; +import { ScorecardClaimTable, type ScorecardClaimEntry } from './ScorecardComparisons'; +import { + CellNote, + EvidenceTable, + gapText, + labelCellStyle, + linkStyle, + numberCellStyle, + rowStyle, + ValidationCard, + ValidationLoading, + ValidationNote, +} from './ValidationDisplay'; /** - * The two validation surfaces for a bill report: - * - BillValidationSection: the tracker pipeline's external checks for - * this bill (official fiscal note, third-party analyses, verdict). - * - ModelTrackRecordSection: the PolicyEngine scorecard's comparisons - * against external analyses for the programs the bill touches. + * The scorecard side of a report's validation: + * - BillExternalEstimates: the tracker pipeline's external checks for a + * bill (official fiscal note, third-party analyses, verdict). + * - BaselineComparisons: the PolicyEngine scorecard's comparisons of the + * model's baseline with published measures, for the programs the + * reform touches. */ function compactMoney(value: number): string { @@ -57,53 +70,7 @@ function metricValue(row: ModelValidationRow, value: number): string { return Math.round(value).toLocaleString(); } -const cellStyle: React.CSSProperties = { - fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, - color: colors.text.primary, - padding: `${spacing.xs} ${spacing.md} ${spacing.xs} 0`, - textAlign: 'left', - verticalAlign: 'top', -}; - -const headCellStyle: React.CSSProperties = { - ...cellStyle, - fontSize: typography.fontSize.xs, - color: colors.text.secondary, - fontWeight: typography.fontWeight.medium, -}; - -function SectionCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** Teal/gray verdict pill, also reused in the report header. */ +/** Teal/gray verdict pill, shown with the bill's external estimates only. */ export function ValidationChip({ validation }: { validation: BillValidation }) { if (validation.withinRange === undefined) { return null; @@ -146,7 +113,18 @@ export function ValidationChip({ validation }: { validation: BillValidation }) { ); } -export function BillValidationSection({ +const VERIFICATION_COPY: Record['overall'], string> = { + confirmed: 'Sources and figures independently re-verified', + partially_confirmed: 'Sources and figures partially re-verified', + refuted: 'Re-verification disputed these figures — treat with caution', + unverifiable: 'Sources could not be independently re-verified', +}; + +/** + * A bill's external estimates beside PolicyEngine's, in one table, with + * the range verdict and what the pipeline noted about the comparison. + */ +export function BillExternalEstimates({ billId, validation, }: { @@ -156,96 +134,78 @@ export function BillValidationSection({ // External numbers render from scorecard-shaped claims, so tracker // rows today and scorecard shard rows tomorrow are interchangeable. const claims = claimsFromBillValidation(billId, validation); + const peEstimate = validation.peEstimate; const range = typeof validation.targetRangeLow === 'number' && typeof validation.targetRangeHigh === 'number' ? [validation.targetRangeLow, validation.targetRangeHigh].sort((a, b) => a - b) : null; return ( - - External checks for this bill - - - {typeof validation.peEstimate === 'number' && ( - - - - - )} - {range && ( - - - - - )} - {claims.map((claim) => ( - - - - - - ))} - -
PolicyEngine estimate{compactMoney(validation.peEstimate)} -
Accepted range - {compactMoney(range[0])} to {compactMoney(range[1])} - -
{claim.source} - {compactMoney(claim.externalValue)} - {claim.sourceUrl && ( - <> - {' '} - - source - - - )} - - {typeof claim.ratio === 'number' ? `${claim.ratio.toFixed(2)}× PE` : ''} -
+ + + {typeof peEstimate === 'number' && ( + + + PolicyEngine + + + {compactMoney(peEstimate)} + + — + + )} + {claims.map((claim) => ( + + + {claim.source} + {claim.sourceUrl && ( + + + Read source + + + )} + + {compactMoney(claim.externalValue)} + {gapText(peEstimate, claim.externalValue)} + + ))} + {range && ( + + + Accepted range + + + {compactMoney(range[0])} to {compactMoney(range[1])} + + + + )} + {validation.drift && ( - + This check predates the current analysis: {validation.drift.reasons.join('; ')}. The - comparison below describes the earlier estimate until validation is re-run. - + comparison describes the earlier estimate until validation is re-run. + )} {validation.verification && ( - - { - { - confirmed: 'Sources and figures independently re-verified', - partially_confirmed: 'Sources and figures partially re-verified', - refuted: 'Re-verification disputed these figures — treat with caution', - unverifiable: 'Sources could not be independently re-verified', - }[validation.verification.overall] - } + + {VERIFICATION_COPY[validation.verification.overall]} {validation.verification.verifiedAt ? ` (${validation.verification.verifiedAt.slice(0, 10)})` : ''} . - + )} {validation.discrepancyExplanation && ( - - {validation.discrepancyExplanation} - + {validation.discrepancyExplanation} )} -
+
); } @@ -260,11 +220,6 @@ export function trackRecordPrograms(trackRecord: ModelTrackRecord): string[] | n return trackRecord ? trackRecord.programs.map((match) => match.program) : trackRecord; } -/** Whether ModelTrackRecordSection renders anything for this track record. */ -export function hasTrackRecordContent(trackRecord: ModelTrackRecord): boolean { - return !trackRecord || trackRecord.programs.length > 0 || trackRecord.reachedCount > 0; -} - /** * Fetches the track record as soon as the paths are known — call this * at page level, not inside a tab panel, so the request runs in @@ -297,140 +252,100 @@ export function useModelTrackRecord(paths: string[]): ModelTrackRecord { return trackRecord; } -export function ModelTrackRecordSection({ +/** The track record's comparison rows, in the scorecard claim shape the tables render. */ +export function trackRecordClaims(trackRecord: ScorecardMatches): ScorecardClaimEntry[] { + return trackRecord.rows.map((row) => { + const program = trackRecord.programs.find((match) => match.program === row.program)!; + const claimId = `${row.source}-${row.program}-${row.metric}-${row.period ?? ''}`; + const claim: RelatedScorecardClaim = { + claim_id: claimId, + country: 'US', + geography: 'US', + name: metricLabel(row), + source: row.source, + url: row.sourceUrl ?? undefined, + metric: row.metric, + unit_concept: row.unitConcept ?? '', + period: row.period ?? '', + reform_framework: 'baseline', + reform_key: 'baseline', + external_value: row.externalValue, + calibration_relationship: row.heldOut ? 'held_out' : 'consumed_as_target', + matchBasis: 'variable', + diagnosis: row.diagnosis ? { title: row.diagnosis } : null, + latest: { + value: row.peValue, + status: row.status, + construction: row.construction ?? undefined, + engine_version: row.engineVersion ?? undefined, + data_bundle: row.dataBundle ?? undefined, + annotations: [ + `${RING_LABELS[program.ring]} · ${describeDepth(program.depth)}`, + `Matched at policyengine-us ${trackRecord.modelVersion}.`, + ...(row.notes ?? []).filter((note) => !note.startsWith('The 2026 column')), + ...(row.calibrationBasis ? [row.calibrationBasis] : []), + ], + ratio: row.ratio, + }, + }; + return { + row: claim, + presentation: { + title: `${PROGRAM_LABELS[row.program] ?? row.program} — ${metricLabel(row)}`, + sourceName: row.sourceName ?? row.source, + externalPeriod: row.period ?? 'Period not recorded', + modelPeriod: row.pePeriod ?? 'Period not recorded', + externalValue: metricValue(row, row.externalValue), + peValue: metricValue(row, row.peValue), + }, + }; + }); +} + +/** + * How the model's baseline compares with published measures of the + * programs the reform touches: the scorecard's track record for those + * programs, then its other baseline claims linked to the reform. + */ +export function BaselineComparisons({ trackRecord, - embedded = false, + scorecardRows, }: { trackRecord: ModelTrackRecord; - embedded?: boolean; + /** The published scorecard's baseline claims for the reform; undefined while loading. */ + scorecardRows: RelatedScorecardClaim[] | null | undefined; }) { - if (trackRecord === null) { - return ( - - - External comparisons are temporarily unavailable — see the{' '} - - PolicyEngine scorecard - {' '} - directly. - - - ); - } - if (trackRecord === undefined) { - return ( - - - - - Loading external comparisons… - - - - ); - } - if (!hasTrackRecordContent(trackRecord)) { - return null; - } - const { programs, rows } = trackRecord; - if (programs.length === 0) { + const entries = [ + ...(trackRecord ? trackRecordClaims(trackRecord) : []), + ...(scorecardRows ?? []).map((row) => ({ row })), + ]; + let content; + if (entries.length > 0) { + content = ; + } else if (trackRecord === undefined || scorecardRows === undefined) { + content = Loading baseline comparisons…; + } else if (trackRecord === null) { + content = Baseline comparisons are temporarily unavailable.; + } else if (trackRecord.reachedCount > 0) { // Silence would read as "fine". State credits are the usual case: the // scorecard measures federal programs and poverty, not state credits. - return ( - - - None of the {trackRecord.reachedCount} variables this reform moves is measured by the{' '} - - PolicyEngine scorecard - - , so there is no external comparison for the programs behind this estimate. - - + content = ( + + None of the {trackRecord.reachedCount} variables this reform moves is measured by the + scorecard. + ); + } else { + content = No baseline comparison covers this reform.; } - - const cards = ( - - {rows.map((row) => { - const program = programs.find((match) => match.program === row.program)!; - return ( - !note.startsWith('The 2026 column')), - ...(row.calibrationBasis ? [row.calibrationBasis] : []), - ], - ratio: row.ratio, - }, - }} - /> - ); - })} - - ); - return embedded ? ( - cards - ) : ( - - Baseline comparisons - {cards} - + {content} + ); } diff --git a/app/src/components/flagship/report/ReportValidation.tsx b/app/src/components/flagship/report/ReportValidation.tsx new file mode 100644 index 000000000..f64ee6efa --- /dev/null +++ b/app/src/components/flagship/report/ReportValidation.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { + CalibrationMatchSection, + type CalibrationPin, +} from '@/components/flagship/CalibrationMatches'; +import { + RelatedReformEstimates, + ScorecardCoverage, + splitScorecardRows, + useScorecardComparisons, + type ScorecardComparisonsState, +} from '@/components/flagship/ScorecardComparisons'; +import { + ValidationCard, + ValidationLoading, + ValidationNote, +} from '@/components/flagship/ValidationDisplay'; +import { BaselineComparisons, type ModelTrackRecord } from '@/components/flagship/ValidationPanel'; +import { Stack } from '@/components/ui'; +import { spacing } from '@/designTokens'; +import type { CalibrationMatches } from '@/libs/flagship/calibrationMatching'; + +export interface ReportValidationProps { + countryId: string; + /** The reform's parameter paths, to match against the scorecard. */ + paths: string[]; + calibration: CalibrationMatches | null | undefined; + calibrationPin?: CalibrationPin; + trackRecord: ModelTrackRecord; + /** The report's own external estimates, e.g. a bill's fiscal note and third-party scores. */ + externalEstimates?: ReactNode; + /** A line under the external estimates, e.g. where the bill's estimate came from. */ + externalEstimatesNote?: ReactNode; +} + +/** + * How this reform's estimate compares with outside scores: the report's + * own (a bill's fiscal note), then published scores of related reforms. + * Without either, one plain line says so. + */ +function ExternalEstimates({ + estimates, + note, + scorecard, +}: { + estimates?: ReactNode; + note?: ReactNode; + scorecard: ScorecardComparisonsState; +}) { + const { reforms } = splitScorecardRows(scorecard); + let fallback: ReactNode = null; + if (!estimates && reforms.length === 0) { + fallback = + scorecard === undefined ? ( + Loading external estimates… + ) : scorecard === null ? ( + External estimates are temporarily unavailable. + ) : ( + No external estimate is available for this reform. + ); + } + return ( + + {estimates} + {fallback} + {note} + {reforms.length > 0 && } + {scorecard && (scorecard.built || scorecard.unmappedCount > 0) && ( + + )} + + ); +} + +/** + * A report's validation, the same for every report: its estimate + * against outside scores, then the model's baseline against the + * official totals it is calibrated to and the published measures the + * scorecard tracks. + */ +export default function ReportValidation({ + countryId, + paths, + calibration, + calibrationPin, + trackRecord, + externalEstimates, + externalEstimatesNote, +}: ReportValidationProps) { + const scorecard = useScorecardComparisons(countryId, paths); + const { baseline } = splitScorecardRows(scorecard); + return ( + + + + + + ); +} diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index 9f88ed1d7..236284f5e 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -2,30 +2,21 @@ import { useState, type ReactNode } from 'react'; import { IconHome, IconPlus } from '@tabler/icons-react'; import type { BillChange } from '@/api/billFeed'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { - CalibrationMatchSection, - type CalibrationPin, -} from '@/components/flagship/CalibrationMatches'; -import ProvisionList from '@/components/flagship/ProvisionList'; -import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; +import type { CalibrationPin } from '@/components/flagship/CalibrationMatches'; import BudgetWindowCard from '@/components/flagship/report/BudgetWindowCard'; import BudgetWindowSummary from '@/components/flagship/report/BudgetWindowSummary'; import DistrictImpactCard from '@/components/flagship/report/DistrictImpactCard'; import EconomicImpactCharts from '@/components/flagship/report/EconomicImpactCharts'; import NoChangeNotice from '@/components/flagship/report/NoChangeNotice'; import ReportChanges from '@/components/flagship/report/ReportChanges'; +import ReportValidation from '@/components/flagship/report/ReportValidation'; import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; import ReportContents, { leadsWithStateRevenue, reportMetrics, type ReportMetric, } from '@/components/flagship/ReportContents'; -import ScorecardComparisons from '@/components/flagship/ScorecardComparisons'; -import { - hasTrackRecordContent, - ModelTrackRecordSection, - type ModelTrackRecord, -} from '@/components/flagship/ValidationPanel'; +import type { ModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Button, Stack, @@ -124,8 +115,13 @@ export interface ReportViewProps { calibration: CalibrationMatches | null | undefined; calibrationPin?: CalibrationPin; trackRecord: ModelTrackRecord; - /** Validation content above the data checks, e.g. a bill's external estimates. */ - validationLead?: ReactNode; + /** + * The report's own external estimates, e.g. a bill's fiscal note. + * Without them, validation says no external estimate is available. + */ + externalEstimates?: ReactNode; + /** A line under the external estimates, e.g. where the bill's estimate came from. */ + externalEstimatesNote?: ReactNode; } /** @@ -159,7 +155,8 @@ export default function ReportView({ calibration, calibrationPin, trackRecord, - validationLead, + externalEstimates, + externalEstimatesNote, }: ReportViewProps) { const nav = useAppNavigate(); const countryId = useCurrentCountry(); @@ -422,35 +419,14 @@ export default function ReportView({ 'validation', - {validationLead} - - 0 ? ( - - - Your reform · {year} - - {provisionsLoading ? ( - - ) : ( - - )} - - ) : ( - Before-and-after details for your reform are unavailable. - ) - } - baselineContent={ - hasTrackRecordContent(trackRecord) && ( - - ) - } + p.path)} + calibration={calibration} + calibrationPin={calibrationPin} + trackRecord={trackRecord} + externalEstimates={externalEstimates} + externalEstimatesNote={externalEstimatesNote} /> )} diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 3987468b7..3df165794 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -10,7 +10,7 @@ import DistrictImpactCard, { import ReportView from '@/components/flagship/report/ReportView'; import RunProgress from '@/components/flagship/report/RunProgress'; import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts'; -import { BillValidationSection, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; +import { BillExternalEstimates, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Button, Stack, Text } from '@/components/ui'; import { getDurationForCountry } from '@/constants/calculationDurations'; import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistrictDataContext'; @@ -328,15 +328,11 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) overviewLead={} calibration={calibration} trackRecord={trackRecord} - validationLead={ - (bill.validation || provenanceNote) && ( - - {bill.validation && ( - - )} - {provenanceNote && Tracker estimate: {provenanceNote}} - - ) + externalEstimates={ + bill.validation && + } + externalEstimatesNote={ + provenanceNote && Tracker estimate: {provenanceNote} } /> ); diff --git a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx index 271a83656..8dcca7f11 100644 --- a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx +++ b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx @@ -1,7 +1,10 @@ -import { fireEvent, render, screen } from '@test-utils'; +import { fireEvent, render, screen, within } from '@test-utils'; import { describe, expect, test } from 'vitest'; import { CalibrationMatchSection } from '@/components/flagship/CalibrationMatches'; -import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { + mockCalibrationMatches, + POPULACE_RELEASE_ID, +} from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { mockReportValidationSnapshot, NEWER_RELEASE_ID, @@ -12,16 +15,16 @@ describe('CalibrationMatchSection', () => { render(); expect(screen.getByText(/temporarily unavailable/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /calibration dashboard/i })).toHaveAttribute( + expect(screen.getByRole('link', { name: /view dashboard/i })).toHaveAttribute( 'href', expect.stringContaining('calibration-diagnostics') ); }); - test('given matches are loading then the spinner state renders', () => { + test('given matches are loading then the loading state renders', () => { render(); - expect(screen.getByText(/checking the calibration/i)).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Checking calibration targets…'); }); test('given reached variables but none calibrated then the gap is stated, not hidden', () => { @@ -34,19 +37,29 @@ describe('CalibrationMatchSection', () => { expect(screen.getByText(/none of the 33 variables .* in CA/i)).toBeInTheDocument(); }); - test('given no provisions at all then nothing renders', () => { - const { container } = render( + test('given no provisions at all then a plain line says no target covers the reform', () => { + render( ); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByRole('heading', { name: 'Data calibration' })).toBeInTheDocument(); + expect(screen.getByText('No calibration target covers this reform.')).toBeInTheDocument(); }); test('shows each target with its period, values, gap and source link', () => { render(); + // One table, so every variable's numbers line up in the same columns. + const table = screen.getByRole('table', { name: 'Calibration targets' }); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect( + within(table) + .getAllByRole('columnheader') + .map((cell) => cell.textContent) + ).toEqual(['Target', 'Period', 'Official', 'PolicyEngine', 'Gap']); expect(screen.getByText('Refundable Child Tax Credit')).toBeInTheDocument(); + expect(screen.getAllByText('IRS Statistics of Income · US')).toHaveLength(2); expect(screen.getByText('940K')).toBeInTheDocument(); expect(screen.getAllByText('1M')[0]).toBeInTheDocument(); expect(screen.getByText('-6.0%')).toBeInTheDocument(); @@ -104,4 +117,11 @@ describe('CalibrationMatchSection', () => { ).toBeInTheDocument(); expect(screen.getByText(/the pinned one is historical/i)).toBeInTheDocument(); }); + + test('given matches then the data release id is collapsed under one label', () => { + render(); + + expect(screen.getByText('Data release')).toBeInTheDocument(); + expect(screen.getByText(`${POPULACE_RELEASE_ID} · policyengine-us 1.808.0`)).not.toBeVisible(); + }); }); diff --git a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx index 0c1dab184..1c257b74f 100644 --- a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx +++ b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx @@ -1,7 +1,13 @@ -import { fireEvent, render, screen, userEvent } from '@test-utils'; -import { describe, expect, test } from 'vitest'; -import { ScorecardComparisonResults } from '@/components/flagship/ScorecardComparisons'; +import { fireEvent, render, renderHook, screen, userEvent, waitFor, within } from '@test-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { + RelatedReformEstimates, + ScorecardClaimTable, + splitScorecardRows, + useScorecardComparisons, +} from '@/components/flagship/ScorecardComparisons'; +import { + CTC_PATH, jctCtcClaim, scorecardClaim, stackedJctCtcClaim, @@ -9,29 +15,10 @@ import { } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; describe('scorecard evidence presentation', () => { - test('given no baseline rows and no baseline content then says none were found', () => { - render( - - ); - expect( - screen.getByText(/No linked baseline comparisons were found in the published scorecard feed/) - ).toBeInTheDocument(); - }); - test('given a related reform then shows source-policy counterparts, periods, baseline caveats and no search button', () => { - render( - - ); - expect(screen.getByText(/not a validation of this report/)).toBeInTheDocument(); - expect(screen.getByText('PolicyEngine estimate')).toBeInTheDocument(); + test('given a related reform then shows source-policy counterparts, periods and baseline caveats', () => { + render(); + expect(screen.getByText(/do not score this exact reform/)).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'PolicyEngine' })).toBeInTheDocument(); expect(screen.getByText(/2026 · fiscal year/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button')); expect(screen.getByText('Different stacking baseline')).toBeInTheDocument(); @@ -39,14 +26,34 @@ describe('scorecard evidence presentation', () => { screen.queryByRole('button', { name: /Find external estimates/ }) ).not.toBeInTheDocument(); }); + + test('given several claims then one table holds them so their numbers line up', () => { + render( + + ); + const table = screen.getByRole('table', { name: 'Related reforms' }); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect( + within(table) + .getAllByRole('columnheader') + .map((cell) => cell.textContent) + ).toEqual(['Comparison', 'Published', 'PolicyEngine', 'Gap']); + // A header row and one row per claim. + expect(within(table).getAllByRole('row')).toHaveLength(3); + expect(within(table).getAllByText('-86.9%')).toHaveLength(2); + }); + test('given an expiry-reversal run then explains its policy and different starting baseline', () => { render( - ); fireEvent.click(screen.getByRole('button')); @@ -54,28 +61,24 @@ describe('scorecard evidence presentation', () => { expect(screen.getByRole('cell', { name: '$1,000', hidden: true })).toBeInTheDocument(); expect(screen.getByText(/broad CTC expansion from an expiry baseline/)).toBeInTheDocument(); }); + test('given a stacked run then describes that run without applying the expiry-reversal explanation', () => { render( - ); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/This run adds the CTC changes after/)).toBeInTheDocument(); expect(screen.queryByText(/This run keeps other enacted tax changes/)).not.toBeInTheDocument(); }); + test('given a Utah baseline comparison then distinguishes claimed and usable credit', () => { render( - ); expect(screen.getByRole('table')).toBeInTheDocument(); @@ -84,40 +87,79 @@ describe('scorecard evidence presentation', () => { expect(screen.getByText(/Utah credits claimed before limiting/)).toBeInTheDocument(); expect(screen.getByText(/Modeled Utah credit actually usable/)).toBeInTheDocument(); }); + test('given an undocumented reform then explicitly identifies missing before-and-after rules', () => { render( - ); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/does not supply a complete before-and-after/)).toBeInTheDocument(); }); - test('given a reform card then policy details are collapsed until opened', async () => { + + test('given a reform row then policy details are collapsed until opened', async () => { render( - ); expect(screen.getByRole('table')).toBeVisible(); await userEvent.click(screen.getByRole('button')); expect(screen.getByRole('dialog')).toBeInTheDocument(); await userEvent.click(screen.getByText('Compared policy and baseline')); - expect(screen.getByRole('table')).toBeVisible(); + expect(screen.getByRole('table', { name: 'Related reforms', hidden: true })).toBeVisible(); }); - test('given an empty feed then absence of matching evidence is explicit', () => { - render(); - expect(screen.getByText(/No linked reform comparisons/)).toBeInTheDocument(); - expect( - screen.getByText(/does not establish that no external estimate exists/) - ).toBeInTheDocument(); +}); + +describe('splitScorecardRows', () => { + test('given mixed claims then reform scores and baseline measures come apart', () => { + const reform = { ...scorecardClaim(), matchBasis: 'parameter' as const }; + const baseline = { ...utahBaselineClaim, matchBasis: 'program' as const }; + + expect(splitScorecardRows({ built: null, unmappedCount: 0, rows: [reform, baseline] })).toEqual( + { reforms: [reform], baseline: [baseline] } + ); + }); + + test('given the feed is loading or unavailable then both lists are empty', () => { + expect(splitScorecardRows(undefined)).toEqual({ reforms: [], baseline: [] }); + expect(splitScorecardRows(null)).toEqual({ reforms: [], baseline: [] }); + }); +}); + +describe('useScorecardComparisons', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given no parameter paths then it resolves empty without a request', () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useScorecardComparisons('uk', [])); + + expect(result.current).toEqual({ built: null, rows: [], unmappedCount: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given paths then it is loading until the feed answers', async () => { + const feed = { built: '2026-08-29', rows: [scorecardClaim()], unmappedCount: 2 }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(feed))); + + const { result } = renderHook(() => useScorecardComparisons('uk', [CTC_PATH])); + + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toEqual(feed)); + }); + + test('given the feed fails then it resolves to null', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 }))); + + const { result } = renderHook(() => useScorecardComparisons('uk', [CTC_PATH])); + + await waitFor(() => expect(result.current).toBeNull()); }); }); diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index 39f0cb0a4..d593c1618 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -1,15 +1,88 @@ -import { render, screen } from '@test-utils'; +import { render, screen, within } from '@test-utils'; import { describe, expect, test } from 'vitest'; import { - BillValidationSection, - hasTrackRecordContent, - ModelTrackRecordSection, + BaselineComparisons, + BillExternalEstimates, + trackRecordClaims, } from '@/components/flagship/ValidationPanel'; +import type { ScorecardMatches } from '@/libs/flagship/modelValidation'; +import { utahBaselineClaim } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const SNAP_AND_CTC_TRACK_RECORD: ScorecardMatches = { + modelVersion: '1.808.0', + reachedCount: 12, + programs: [ + { program: 'snap', variable: 'snap', depth: 1, ring: 'primary' }, + { program: 'ctc_refund', variable: 'refundable_ctc', depth: 2, ring: 'mechanism' }, + ], + rows: [ + { + source: 'urban-sotsn', + sourceName: 'Urban Institute — State of the Safety Net 2025', + sourceUrl: 'https://apps.urban.org/features/state-safety-net/', + program: 'snap', + metric: 'eligible_count', + period: '2023 average month', + status: 'comparable', + unitConcept: 'persons', + externalValue: 69128000, + peValue: 66363627, + ratio: 0.96, + heldOut: true, + policyengineVariables: ['snap', 'is_snap_eligible'], + }, + { + source: 'urban-sotsn', + sourceName: 'Urban Institute — State of the Safety Net 2025', + sourceUrl: 'https://apps.urban.org/features/state-safety-net/', + program: 'ctc_refund', + metric: 'eligibility_rate', + period: '2023 average month', + status: 'constructed', + unitConcept: 'tax units', + externalValue: 0.12, + peValue: 0.114, + ratio: 0.95, + heldOut: false, + policyengineVariables: ['refundable_ctc'], + }, + ], +}; + +describe('BillExternalEstimates', () => { + test('given a bill with a fiscal note then every estimate shares one aligned table', () => { + render( + + ); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + const cells = (name: string) => + within(within(table).getByRole('row', { name: new RegExp(`^${name}`) })) + .getAllByRole('cell') + .map((cell) => cell.textContent); + expect(cells('PolicyEngine')).toEqual(['-$225.5B', '—']); + expect(cells('Official fiscal note')).toEqual(['-$230.0B', '2.0%']); + expect(cells('CRFB')).toEqual(['-$220.0B', '-2.5%']); + expect(cells('Accepted range')).toEqual(['-$250.0B to -$210.0B', '']); + expect(screen.getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect(screen.getByText(/later effective date/)).toBeInTheDocument(); + }); + + test('given a bill outside the range then the chip says so', () => { + render( + + ); + + expect(screen.getByText('Outside fiscal-note range · Δ 31%')).toBeInTheDocument(); + }); -describe('BillValidationSection', () => { test('given drifted validation then the chip demands a re-check and the note explains', () => { render( - { }); }); -describe('ModelTrackRecordSection', () => { - const ctcMatch = { - program: 'ctc_refund', - variable: 'refundable_ctc', - depth: 2, - ring: 'mechanism' as const, - }; - - test('given the scorecard is unreachable then an honest note renders, not a blank tab', () => { - render(); +describe('BaselineComparisons', () => { + test('given the scorecard is unreachable then an honest note renders, not a blank card', () => { + render(); expect(screen.getByText(/temporarily unavailable/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /policyengine scorecard/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view scorecard/i })).toBeInTheDocument(); }); - test('given matches are loading then the spinner state renders', () => { - render(); + test('given matches are loading then the loading state renders', () => { + render(); - expect(screen.getByText(/loading external comparisons/i)).toBeInTheDocument(); + expect(screen.getByText(/loading baseline comparisons/i)).toBeInTheDocument(); }); - test('given the reform reaches nothing then nothing renders', () => { - const { container } = render( - { + render( + ); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByText('No baseline comparison covers this reform.')).toBeInTheDocument(); }); test('given reached variables the scorecard does not measure then the note says so', () => { render( - ); @@ -69,53 +137,21 @@ describe('ModelTrackRecordSection', () => { ).toBeInTheDocument(); }); - test('given rows then each program heads its comparisons with its distance from the reform', () => { + test('given track record and scorecard rows then they share one table', () => { render( - ); - expect(screen.getByText('Baseline comparisons')).toBeInTheDocument(); - expect(screen.getByText('SNAP — Eligible people')).toBeInTheDocument(); - expect(screen.getByText('Refundable CTC — Eligibility rate')).toBeInTheDocument(); - expect(screen.getAllByRole('table')).toHaveLength(2); + expect(screen.getByRole('heading', { name: 'Baseline comparisons' })).toBeInTheDocument(); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'SNAP — Eligible people' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Refundable CTC — Eligibility rate' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Utah Child Tax Credit' })).toBeInTheDocument(); expect(screen.queryByText(/formula steps/)).not.toBeInTheDocument(); expect(screen.getByText('69.1M')).toBeInTheDocument(); expect(screen.getAllByRole('link', { name: 'Read source' })[0]).toHaveAttribute( @@ -125,18 +161,13 @@ describe('ModelTrackRecordSection', () => { }); }); -describe('hasTrackRecordContent', () => { - test('given loading, unavailable, or reached variables then the section has content', () => { - expect(hasTrackRecordContent(undefined)).toBe(true); - expect(hasTrackRecordContent(null)).toBe(true); - expect( - hasTrackRecordContent({ modelVersion: '1.808.0', reachedCount: 30, programs: [], rows: [] }) - ).toBe(true); - }); +describe('trackRecordClaims', () => { + test('given track record rows then each becomes a baseline claim with its program in the title', () => { + const [snap] = trackRecordClaims(SNAP_AND_CTC_TRACK_RECORD); - test('given the reform reaches nothing then the section has no content', () => { - expect( - hasTrackRecordContent({ modelVersion: '1.808.0', reachedCount: 0, programs: [], rows: [] }) - ).toBe(false); + expect(snap.row.reform_framework).toBe('baseline'); + expect(snap.row.calibration_relationship).toBe('held_out'); + expect(snap.presentation?.title).toBe('SNAP — Eligible people'); + expect(snap.presentation?.externalValue).toBe('69.1M'); }); }); diff --git a/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx new file mode 100644 index 000000000..c02c05f81 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx @@ -0,0 +1,155 @@ +import { render, screen, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportValidation from '@/components/flagship/report/ReportValidation'; +import type { ScorecardComparisonsState } from '@/components/flagship/ScorecardComparisons'; +import { BillExternalEstimates } from '@/components/flagship/ValidationPanel'; +import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { + CTC_PATH, + scorecardClaim, + utahBaselineClaim, +} from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockUseScorecardComparisons = vi.fn<() => ScorecardComparisonsState>(); + +vi.mock('@/components/flagship/ScorecardComparisons', async (importOriginal) => ({ + ...(await importOriginal()), + useScorecardComparisons: () => mockUseScorecardComparisons(), +})); + +const NO_SCORECARD_ROWS = { built: '2026-08-29', rows: [], unmappedCount: 4 }; +const RELATED_REFORM = { ...scorecardClaim(), matchBasis: 'parameter' as const }; +const UTAH_BASELINE = { ...utahBaselineClaim, matchBasis: 'program' as const }; +const EMPTY_TRACK_RECORD = { modelVersion: '1.808.0', reachedCount: 12, programs: [], rows: [] }; + +function renderValidation(props: Partial[0]> = {}) { + return render( + + ); +} + +const card = (name: string) => + screen.getByRole('heading', { level: 3, name }).closest('section') as HTMLElement; + +describe('ReportValidation', () => { + beforeEach(() => { + mockUseScorecardComparisons.mockReset(); + mockUseScorecardComparisons.mockReturnValue(NO_SCORECARD_ROWS); + }); + + test('given a custom report then the external estimates, calibration and baseline cards render in order', () => { + renderValidation(); + + expect( + screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent) + ).toEqual(['External estimates', 'Data calibration', 'Baseline comparisons']); + }); + + test('given a custom report without external estimates then one plain line says so', () => { + renderValidation(); + + expect( + within(card('External estimates')).getByText( + 'No external estimate is available for this reform.' + ) + ).toBeInTheDocument(); + expect(within(card('External estimates')).queryByRole('table')).not.toBeInTheDocument(); + // The calibration comparison is there all the same. + expect( + within(card('Data calibration')).getByRole('table', { name: 'Calibration targets' }) + ).toBeInTheDocument(); + }); + + test('given a custom report with scorer estimates then they show instead of the plain line', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [RELATED_REFORM], + }); + renderValidation(); + + const estimates = card('External estimates'); + expect(within(estimates).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument(); + expect(within(estimates).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); + expect(within(estimates).getByText('Joint Committee on Taxation')).toBeInTheDocument(); + expect( + within(estimates).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given a bill report then its fiscal note leads the external estimates', () => { + renderValidation({ + externalEstimates: ( + + ), + externalEstimatesNote: 'Tracker estimate: policyengine-us 1.729.3', + }); + + const estimates = card('External estimates'); + expect( + within(estimates).getByRole('table', { name: 'Estimates for this bill' }) + ).toBeInTheDocument(); + expect(within(estimates).getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect( + within(estimates).getByText('Tracker estimate: policyengine-us 1.729.3') + ).toBeInTheDocument(); + expect( + within(estimates).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given scorecard baseline claims then they join the baseline comparisons, not the estimates', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [UTAH_BASELINE], + }); + renderValidation(); + + expect( + within(card('Baseline comparisons')).getByRole('button', { name: 'Utah Child Tax Credit' }) + ).toBeInTheDocument(); + expect( + within(card('External estimates')).getByText( + 'No external estimate is available for this reform.' + ) + ).toBeInTheDocument(); + }); + + test('given the scorecard is still loading then the estimates card says it is loading', () => { + mockUseScorecardComparisons.mockReturnValue(undefined); + renderValidation(); + + expect(within(card('External estimates')).getByRole('status')).toHaveTextContent( + 'Loading external estimates…' + ); + }); + + test('given the scorecard is unavailable then the estimates card says so, not that none exists', () => { + mockUseScorecardComparisons.mockReturnValue(null); + renderValidation(); + + expect( + within(card('External estimates')).getByText( + 'External estimates are temporarily unavailable.' + ) + ).toBeInTheDocument(); + expect( + screen.queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given the coverage of the scorecard match then it is collapsed under the estimates', () => { + renderValidation(); + + expect(within(card('External estimates')).getByText('Coverage')).toBeInTheDocument(); + expect( + within(card('External estimates')).getByText(/4 claims in this country have no usable/) + ).not.toBeVisible(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx index 20e63279a..8925e7dc9 100644 --- a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx @@ -1,7 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; -import { describe, expect, test, vi } from 'vitest'; +import { render, screen, userEvent, within } from '@test-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import ReportView from '@/components/flagship/report/ReportView'; +import { BillExternalEstimates } from '@/components/flagship/ValidationPanel'; +import type { RunReportProvision } from '@/libs/flagship/runReport'; +import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; vi.mock('@/utils/isSocietyWideReportNoOp', () => ({ @@ -107,3 +111,73 @@ describe('ReportView cover', () => { expect(screen.queryByText(/Hand-built/)).not.toBeInTheDocument(); }); }); + +describe('ReportView validation', () => { + const CTC_PROVISION: RunReportProvision = { + path: 'gov.irs.credits.ctc.refundable.individual_max', + breadcrumb: 'IRS → Credits → CTC → Refundable maximum', + unit: 'currency-USD', + baselineValue: 1700, + value: 499, + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderValidationTab = async (extra: Partial[0]> = {}) => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + return screen.getByRole('tabpanel'); + }; + + test('given a custom report then validation says no external estimate is available', async () => { + const panel = await renderValidationTab(); + + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect(within(panel).getByRole('table', { name: 'Calibration targets' })).toBeInTheDocument(); + expect( + within(panel).getByRole('heading', { level: 3, name: 'Baseline comparisons' }) + ).toBeInTheDocument(); + // The reform's changes live in the overview; validation does not list them again. + expect(within(panel).queryByText(/Your reform/)).not.toBeInTheDocument(); + expect(within(panel).queryByText('IRS → Credits → CTC → Refundable maximum')).toBeNull(); + }); + + test('given a bill report then validation leads with its fiscal note and the range chip', async () => { + const panel = await renderValidationTab({ + externalEstimates: ( + + ), + }); + + expect( + within(panel).getByRole('table', { name: 'Estimates for this bill' }) + ).toBeInTheDocument(); + expect(within(panel).getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect( + within(panel).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index 52e6bcb69..26a51fa85 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, within } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { BillEconomy } from '@/hooks/useBillEconomy'; import BillReportPage from '@/pages/flagship/BillReport.page'; import { clearMetadata } from '@/reducers/metadataReducer'; @@ -97,6 +97,10 @@ describe('BillReportPage', () => { store.dispatch(clearMetadata()); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + test('given a bill then it uses the saved report sections', async () => { renderReport(); @@ -233,7 +237,10 @@ describe('BillReportPage', () => { await user.click(await screen.findByRole('tab', { name: 'Validation' })); - expect(screen.getByText('External checks for this bill')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { level: 3, name: 'External estimates' }) + ).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Estimates for this bill' })).toBeInTheDocument(); expect(screen.getByText('-$225.5B')).toBeInTheDocument(); expect(screen.getByText('CRFB')).toBeInTheDocument(); expect(screen.getByText(/later effective date/)).toBeInTheDocument(); @@ -244,6 +251,29 @@ describe('BillReportPage', () => { ).toBeInTheDocument(); }); + test('given a bill without external checks then validation says no estimate is available', async () => { + const user = userEvent.setup(); + // Each request gets its own body: the scorecard feeds answer with no rows. + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockFetchTrackerBills.mockResolvedValue([{ ...TRACKED_BILL, validation: undefined }]); + renderReport(); + + await user.click(await screen.findByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect(within(panel).queryByText(/fiscal-note range/)).not.toBeInTheDocument(); + // The tracker's provenance still says where the stored estimate came from. + expect( + within(panel).getByText(/^Tracker estimate: policyengine-us 1\.729\.3/) + ).toBeInTheDocument(); + }); + test('given a state bill then its data check runs against that state and says so', async () => { const user = userEvent.setup(); const SNAP_STANDARD_DEDUCTION_PATH = 'gov.usda.snap.income.deductions.standard'; diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 03471b685..acb3ac99c 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, userEvent, within } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockCalcStatus = vi.fn(); @@ -76,6 +77,10 @@ describe('FlagshipReportPage', () => { mockCalcStatus.mockReset(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + test('given the run is still calculating then the page is the wait, without the adjust panel', () => { mockCalcStatus.mockReturnValue({ status: 'pending', @@ -105,4 +110,32 @@ describe('FlagshipReportPage', () => { expect(screen.queryByText('Adjust parameters')).not.toBeInTheDocument(); }); + + test('given a finished custom report then validation shows both checks and says no external estimate exists', async () => { + const user = userEvent.setup(); + // The scorecard links no published score to this reform. + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect( + within(panel) + .getAllByRole('heading', { level: 3 }) + .map((heading) => heading.textContent) + ).toEqual(['External estimates', 'Data calibration', 'Baseline comparisons']); + }); }); From f5440f3e5172eb561a27f29fd52804eec6df4280 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:53:27 -0400 Subject: [PATCH 03/27] Report: test validation with a related published score Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/pages/flagship/Report.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index acb3ac99c..840fd7c69 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, within } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { scorecardClaim } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockCalcStatus = vi.fn(); @@ -138,4 +139,38 @@ describe('FlagshipReportPage', () => { .map((heading) => heading.textContent) ).toEqual(['External estimates', 'Data calibration', 'Baseline comparisons']); }); + + const COMPLETE = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }; + + test('given a finished custom report with a related published score then validation shows it', async () => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + built: '2026-08-29', + rows: [{ ...scorecardClaim(), matchBasis: 'parameter' }], + unmappedCount: 0, + }) + ) + ); + mockCalcStatus.mockReturnValue(COMPLETE); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByRole('table', { name: 'Related reforms' }) + ).toBeInTheDocument(); + expect(within(panel).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); + expect( + within(panel).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); }); From 19abf25f0a053c92ff9586727c28395125ed51ed Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:53:39 -0400 Subject: [PATCH 04/27] Validation: scorecard not comparable against a custom baseline External scores and the scorecard's published measures assume current law. A report compared with another policy now marks both scorecard cards "Not comparable", names what it compares with, and does not ask the scorecard. Data calibration still shows. Reports on current law are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ValidationDisplay.tsx | 14 +++++- .../components/flagship/ValidationPanel.tsx | 15 +++++- .../flagship/report/ReportValidation.tsx | 25 +++++++++- .../components/flagship/report/ReportView.tsx | 1 + .../flagship/report/ReportValidation.test.tsx | 46 ++++++++++++++++++- .../flagship/report/ReportView.test.tsx | 19 ++++++++ .../tests/unit/pages/flagship/Report.test.tsx | 32 ++++++++++++- 7 files changed, 146 insertions(+), 6 deletions(-) diff --git a/app/src/components/flagship/ValidationDisplay.tsx b/app/src/components/flagship/ValidationDisplay.tsx index 1ad7d597b..e05549c32 100644 --- a/app/src/components/flagship/ValidationDisplay.tsx +++ b/app/src/components/flagship/ValidationDisplay.tsx @@ -1,6 +1,6 @@ import type { CSSProperties, ReactNode } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; -import { Spinner, Stack, Text } from '@/components/ui'; +import { Badge, Spinner, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; /** @@ -146,6 +146,18 @@ export function ValidationNote({ children }: { children: ReactNode }) { ); } +/** A check that does not apply to this report, marked as such, with the reason. */ +export function NotComparable({ children }: { children: ReactNode }) { + return ( + + + Not comparable + + {children} + + ); +} + /** A card's check is still loading; the line, not the spinner, is what gets announced. */ export function ValidationLoading({ children }: { children: ReactNode }) { return ( diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index dd0bd9fc7..8db215dce 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -20,6 +20,7 @@ import { gapText, labelCellStyle, linkStyle, + NotComparable, numberCellStyle, rowStyle, ValidationCard, @@ -310,17 +311,29 @@ export function trackRecordClaims(trackRecord: ScorecardMatches): ScorecardClaim export function BaselineComparisons({ trackRecord, scorecardRows, + baselineLabel, }: { trackRecord: ModelTrackRecord; /** The published scorecard's baseline claims for the reform; undefined while loading. */ scorecardRows: RelatedScorecardClaim[] | null | undefined; + /** + * What the report compares with, when not current law. The scorecard + * measures current law, so its comparisons do not apply. + */ + baselineLabel?: string | null; }) { const entries = [ ...(trackRecord ? trackRecordClaims(trackRecord) : []), ...(scorecardRows ?? []).map((row) => ({ row })), ]; let content; - if (entries.length > 0) { + if (baselineLabel) { + content = ( + + The scorecard measures current law. This report compares with {baselineLabel}. + + ); + } else if (entries.length > 0) { content = ; } else if (trackRecord === undefined || scorecardRows === undefined) { content = Loading baseline comparisons…; diff --git a/app/src/components/flagship/report/ReportValidation.tsx b/app/src/components/flagship/report/ReportValidation.tsx index f64ee6efa..9015c3472 100644 --- a/app/src/components/flagship/report/ReportValidation.tsx +++ b/app/src/components/flagship/report/ReportValidation.tsx @@ -11,6 +11,7 @@ import { type ScorecardComparisonsState, } from '@/components/flagship/ScorecardComparisons'; import { + NotComparable, ValidationCard, ValidationLoading, ValidationNote, @@ -31,6 +32,11 @@ export interface ReportValidationProps { externalEstimates?: ReactNode; /** A line under the external estimates, e.g. where the bill's estimate came from. */ externalEstimatesNote?: ReactNode; + /** + * What the report compares with, when not current law. External scores + * assume current law, so the scorecard is marked not comparable. + */ + baselineLabel?: string | null; } /** @@ -42,11 +48,22 @@ function ExternalEstimates({ estimates, note, scorecard, + baselineLabel, }: { estimates?: ReactNode; note?: ReactNode; scorecard: ScorecardComparisonsState; + baselineLabel?: string | null; }) { + if (baselineLabel) { + return ( + + + External estimates assume current law. This report compares with {baselineLabel}. + + + ); + } const { reforms } = splitScorecardRows(scorecard); let fallback: ReactNode = null; if (!estimates && reforms.length === 0) { @@ -72,6 +89,8 @@ function ExternalEstimates({ ); } +const NO_PATHS: string[] = []; + /** * A report's validation, the same for every report: its estimate * against outside scores, then the model's baseline against the @@ -86,8 +105,10 @@ export default function ReportValidation({ trackRecord, externalEstimates, externalEstimatesNote, + baselineLabel, }: ReportValidationProps) { - const scorecard = useScorecardComparisons(countryId, paths); + // Against another baseline the scorecard is not shown, so it is not asked. + const scorecard = useScorecardComparisons(countryId, baselineLabel ? NO_PATHS : paths); const { baseline } = splitScorecardRows(scorecard); return ( @@ -95,11 +116,13 @@ export default function ReportValidation({ estimates={externalEstimates} note={externalEstimatesNote} scorecard={scorecard} + baselineLabel={baselineLabel} /> ); diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index 236284f5e..1792c707d 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -427,6 +427,7 @@ export default function ReportView({ trackRecord={trackRecord} externalEstimates={externalEstimates} externalEstimatesNote={externalEstimatesNote} + baselineLabel={customBaselineLabel} /> )} diff --git a/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx index c02c05f81..0269d3603 100644 --- a/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx @@ -11,17 +11,20 @@ import { } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; -const mockUseScorecardComparisons = vi.fn<() => ScorecardComparisonsState>(); +const mockUseScorecardComparisons = + vi.fn<(countryId: string, paths: string[]) => ScorecardComparisonsState>(); vi.mock('@/components/flagship/ScorecardComparisons', async (importOriginal) => ({ ...(await importOriginal()), - useScorecardComparisons: () => mockUseScorecardComparisons(), + useScorecardComparisons: (countryId: string, paths: string[]) => + mockUseScorecardComparisons(countryId, paths), })); const NO_SCORECARD_ROWS = { built: '2026-08-29', rows: [], unmappedCount: 4 }; const RELATED_REFORM = { ...scorecardClaim(), matchBasis: 'parameter' as const }; const UTAH_BASELINE = { ...utahBaselineClaim, matchBasis: 'program' as const }; const EMPTY_TRACK_RECORD = { modelVersion: '1.808.0', reachedCount: 12, programs: [], rows: [] }; +const SAVED_REFORM_BASELINE = 'Smaller CTC'; function renderValidation(props: Partial[0]> = {}) { return render( @@ -152,4 +155,43 @@ describe('ReportValidation', () => { within(card('External estimates')).getByText(/4 claims in this country have no usable/) ).not.toBeVisible(); }); + + test('given a custom baseline then both scorecard cards are marked not comparable', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [RELATED_REFORM, UTAH_BASELINE], + }); + renderValidation({ baselineLabel: SAVED_REFORM_BASELINE }); + + for (const name of ['External estimates', 'Baseline comparisons']) { + expect(within(card(name)).getByText('Not comparable')).toBeInTheDocument(); + expect(within(card(name)).queryByRole('table')).not.toBeInTheDocument(); + } + expect( + within(card('External estimates')).getByText( + 'External estimates assume current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + expect( + within(card('Baseline comparisons')).getByText( + 'The scorecard measures current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + }); + + test('given a custom baseline then the scorecard is not asked and calibration still shows', () => { + renderValidation({ baselineLabel: SAVED_REFORM_BASELINE }); + + expect(mockUseScorecardComparisons).toHaveBeenCalledWith('us', []); + expect( + within(card('Data calibration')).getByRole('table', { name: 'Calibration targets' }) + ).toBeInTheDocument(); + }); + + test('given current law then the scorecard is asked for the reform paths and nothing is marked', () => { + renderValidation(); + + expect(mockUseScorecardComparisons).toHaveBeenCalledWith('us', [CTC_PATH]); + expect(screen.queryByText('Not comparable')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx index 8925e7dc9..14d3073e2 100644 --- a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx @@ -180,4 +180,23 @@ describe('ReportView validation', () => { within(panel).queryByText('No external estimate is available for this reform.') ).not.toBeInTheDocument(); }); + + test('given a custom baseline then validation marks the scorecard not comparable', async () => { + const panel = await renderValidationTab({ customBaselineLabel: 'Smaller CTC' }); + + expect(within(panel).getAllByText('Not comparable')).toHaveLength(2); + expect(within(panel).getByRole('table', { name: 'Calibration targets' })).toBeInTheDocument(); + // External scores assume current law: the scorecard is not asked. + const urls = vi.mocked(fetch).mock.calls.map(([url]) => String(url)); + expect(urls).not.toContain('/api/scorecard-comparisons'); + }); + + test('given current law then validation asks the scorecard and marks nothing', async () => { + const panel = await renderValidationTab(); + + await within(panel).findByText('No external estimate is available for this reform.'); + const urls = vi.mocked(fetch).mock.calls.map(([url]) => String(url)); + expect(urls).toContain('/api/scorecard-comparisons'); + expect(within(panel).queryByText('Not comparable')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 840fd7c69..687398c71 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -6,6 +6,8 @@ import { scorecardClaim } from '@/tests/fixtures/libs/flagship/scorecardComparis import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockCalcStatus = vi.fn(); +// The report's policies; a baseline policy with parameters is a custom baseline. +const mockPolicies = vi.hoisted(() => ({ current: [] as unknown[] })); vi.mock('@/hooks/useFlagshipReport', () => ({ isApiReportId: () => true, @@ -15,7 +17,7 @@ vi.mock('@/hooks/useFlagshipReport', () => ({ { id: 's1', populationId: 'us', policyId: '2' }, { id: 's2', populationId: 'us', policyId: '9' }, ], - policies: [], + policies: mockPolicies.current, isLoading: false, }), })); @@ -76,6 +78,7 @@ function renderPage() { describe('FlagshipReportPage', () => { beforeEach(() => { mockCalcStatus.mockReset(); + mockPolicies.current = []; }); afterEach(() => { @@ -173,4 +176,31 @@ describe('FlagshipReportPage', () => { within(panel).queryByText('No external estimate is available for this reform.') ).not.toBeInTheDocument(); }); + + test('given a finished report against a saved reform then the scorecard is not comparable', async () => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockPolicies.current = [ + { + id: '2', + label: 'Smaller CTC', + parameters: [{ name: 'gov.irs.credits.ctc.refundable.individual_max', values: [] }], + }, + ]; + mockCalcStatus.mockReturnValue(COMPLETE); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + within(panel).getByText( + 'External estimates assume current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + expect(within(panel).getAllByText('Not comparable')).toHaveLength(2); + }); }); From 97c2f41d3a58e2ecbdfdcd4e905f57bbc465a11f Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:54:00 -0400 Subject: [PATCH 05/27] Reforms: bills read as the tracker names them One small mapping in libs/flagship/billTracker.ts mirrors the bill tracker: its place names ("Federal"), its bill-number fallback for a row with no title, its join of research rows to processed_bills (title number first, then the id; spacing and leading zeros ignored), and its page for each bill. The feed now carries that page as trackerUrl, and reads netCost where a row stores no revenue change, as the tracker does. The stage stays the tracker's own word. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 36 +++-- app/src/libs/flagship/billTracker.ts | 141 ++++++++++++++++++ .../libs/flagship/billTrackerMocks.ts | 27 ++++ app/src/tests/unit/api/billFeed.test.ts | 65 ++++++++ .../unit/libs/flagship/billTracker.test.ts | 122 +++++++++++++++ 5 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 app/src/libs/flagship/billTracker.ts create mode 100644 app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts create mode 100644 app/src/tests/unit/libs/flagship/billTracker.test.ts diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index fa12bfc64..f263a6aaf 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -1,5 +1,12 @@ import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; +import { + processedBillKey, + researchBillKeys, + trackerBillTitle, + trackerBillUrl, + trackerPlace, +} from '@/libs/flagship/billTracker'; import type { ValueInterval } from '@/types/subIngredients/valueInterval'; /** @@ -104,6 +111,10 @@ export interface TrackedBill { /** Two-letter state code for state bills; absent for federal ones. */ state?: string; title: string; + /** + * The bill's stage in the tracker's words ("In Committee"), or + * "Analyzed" when the tracker follows no stage for it. + */ status: string; summary: string; provisions: TrackedBillProvision[]; @@ -114,6 +125,8 @@ export interface TrackedBill { legiscanUrl?: string; /** The bill's source page (congress.gov / state legislature), when known. */ sourceUrl?: string; + /** The bill's page in the bill tracker. */ + trackerUrl?: string; /** Analysis date (ISO), for newest-first ordering. */ date?: string; /** Model and data versions behind the stored analysis. */ @@ -396,7 +409,8 @@ export function changesFromTrackerProvisions(raw: unknown): BillChange[] { /** Headline numbers from a reform_impacts row, when present and numeric. */ function extractImpacts(impact: any): TrackedBill['impacts'] { - const revenue = impact?.budgetary_impact?.stateRevenueImpact; + // As the tracker reads it: net cost stands in where no revenue change is stored. + const revenue = impact?.budgetary_impact?.stateRevenueImpact ?? impact?.budgetary_impact?.netCost; const poverty = impact?.poverty_impact?.percentChange; const result: NonNullable = {}; if (typeof revenue === 'number' && Number.isFinite(revenue) && revenue !== 0) { @@ -538,35 +552,37 @@ export async function fetchTrackerBills(): Promise { const impactsById = new Map((impacts ?? []).map((impact) => [impact.id, impact])); const baselineById = new Map((baselines ?? []).map((row) => [row.id, row.baseline_params])); const validationById = new Map((validations ?? []).map((row) => [row.id, row])); + // Joined as the tracker joins them, so a bill shows the tracker's stage. const processedByKey = new Map( - (processed ?? []).map((bill) => [ - `${String(bill.state).toLowerCase()}-${String(bill.bill_number).toLowerCase().replace(/\s+/g, '')}`, - bill, - ]) + (processed ?? []).map((bill) => [processedBillKey(bill.state, bill.bill_number), bill]) ); return research .filter((record) => isScorableReform(record, impactsById.get(record.id))) .map((record): TrackedBill => { const impact = impactsById.get(record.id); - const status = processedByKey.get(record.id)?.status; + const rows = researchBillKeys(record) + .map((key) => processedByKey.get(key)) + .filter(Boolean); + const tracked = rows.find((row) => row.status) ?? rows[0]; return { id: record.id, countryId: 'us', - jurisdiction: STATE_NAMES[record.state] ?? record.state ?? 'Federal', + jurisdiction: trackerPlace(record.state, STATE_NAMES), // The tracker files federal bills under "US", which is not a state. state: /^[a-z]{2}$/i.test(record.state ?? '') && record.state.toUpperCase() !== 'US' ? record.state.toUpperCase() : undefined, - title: record.title ?? record.id, - status: status || 'Analyzed', + title: trackerBillTitle(record), + status: tracked?.status || 'Analyzed', summary: record.description ?? '', provisions: provisionsFromReformParams(impact?.reform_params, baselineById.get(record.id)), changes: changesFromTrackerProvisions(impact?.provisions), keyFindings: Array.isArray(record.key_findings) ? record.key_findings : undefined, - legiscanUrl: processedByKey.get(record.id)?.legiscan_url ?? undefined, + legiscanUrl: tracked?.legiscan_url ?? undefined, sourceUrl: record.url ?? undefined, + trackerUrl: trackerBillUrl(record), date: record.date ?? undefined, provenance: impact ? { diff --git a/app/src/libs/flagship/billTracker.ts b/app/src/libs/flagship/billTracker.ts new file mode 100644 index 000000000..f224f60d9 --- /dev/null +++ b/app/src/libs/flagship/billTracker.ts @@ -0,0 +1,141 @@ +import { WEBSITE_URL } from '@/constants'; + +/** + * The bill tracker's names and pages for a bill, so a bill reads the same + * in app v3 and in the tracker, and each links to the other. + * + * The rules mirror the tracker's own code (PolicyEngine/state-legislative-tracker): + * its page routes (src/App.jsx, src/components/RedesignHome.jsx), its place + * names (src/components/Breadcrumb.jsx), its bill number fallback + * (src/context/DataContext.jsx), and its join of research rows to + * processed_bills (src/components/StatePanel.jsx, RedesignHome.jsx). + * A bill's stage stays the tracker's own word; reading it is billStage's job. + */ + +/** Where policyengine.org serves the tracker (its BASE_PATH). */ +export const BILL_TRACKER_URL = `${WEBSITE_URL}/us/bill-tracker`; + +/** The research row fields the tracker's routes and labels read. */ +export interface TrackerResearchRow { + id: string; + state?: string | null; + title?: string | null; + /** "bill", "blog", "dashboard", "tool". */ + type?: string | null; + /** "published", "in_progress", "in_review", ... */ + status?: string | null; +} + +/** The tracker files federal work under "US"; older federal rows say "all" or "federal". */ +const FEDERAL_CODES = new Set(['US', 'ALL', 'FEDERAL']); + +/** An id as a URL may carry it: percent-encoded, or in other case. */ +function comparableId(id: string): string { + let decoded = id; + try { + decoded = decodeURIComponent(id); + } catch { + // A stray "%" is part of the id. + } + return decoded.trim().toLowerCase(); +} + +/** + * The tracker's page for a research row, as the tracker links it: the + * bill's own page (/us/bill-tracker/{state}/{id}, "us" for a federal + * bill), the state's page for a row that is no bill page (a blog post, a + * row in review), and the tracker's home for an older federal row. + */ +export function trackerBillUrl(row: TrackerResearchRow): string { + const code = (row.state ?? '').trim().toLowerCase(); + const id = encodeURIComponent(row.id); + if (!/^[a-z]{2}$/.test(code)) { + return FEDERAL_CODES.has(code.toUpperCase()) && row.id.startsWith('us-') + ? `${BILL_TRACKER_URL}/us/${id}` + : BILL_TRACKER_URL; + } + return row.type === 'bill' && row.status !== 'in_review' + ? `${BILL_TRACKER_URL}/${code}/${id}` + : `${BILL_TRACKER_URL}/${code}`; +} + +/** + * The tracker's page for a bill known only by its id, as a report URL + * names it: tracker ids lead with the place ("ks-hb2620", "us-hr1425"). + */ +export function trackerUrlForBillId(billId: string): string { + const id = comparableId(billId); + return trackerBillUrl({ id, state: id.split('-')[0], type: 'bill' }); +} + +/** The place as the tracker names it: "Federal", or the state's name. */ +export function trackerPlace(state: unknown, stateNames: Record): string { + const code = typeof state === 'string' ? state.trim().toUpperCase() : ''; + if (!code || FEDERAL_CODES.has(code)) { + return 'Federal'; + } + return stateNames[code] ?? String(state); +} + +/** + * The bill's name as the tracker shows it: its title, or without one the + * bill number the id carries ("ut-sb60" is "SB60"). + */ +export function trackerBillTitle(row: Pick): string { + const title = row.title?.trim(); + if (title) { + return title; + } + const parts = row.id.split('-'); + return (parts.length >= 2 ? parts.slice(1).join('-') : row.id).toUpperCase(); +} + +/** A bill number across spacing and leading-zero variants: "HB 0210" is "HB210". */ +function billNumberKey(billNumber: string): string { + return billNumber + .replace(/[\s.]+/g, '') + .toUpperCase() + .replace(/^([A-Z]+)0+(\d)/, '$1$2'); +} + +/** The key the tracker joins a processed_bills row by: "UT:HB210". */ +export function processedBillKey(state: unknown, billNumber: unknown): string { + return `${String(state ?? '').toUpperCase()}:${billNumberKey(String(billNumber ?? ''))}`; +} + +/** A bill number in a title ("UT HB 210: …" has "HB 210"), as the tracker reads it. */ +const TITLE_BILL_NUMBER = /\b(?!FY)([A-Z]{1,3}\.?\s*\d+(?:\s*S\d+)?)/i; + +/** + * The keys a research row's processed_bills row may sit under, in the + * order the tracker tries them: the bill number in its title, then the + * one its id carries ("ut-hb-210" and "ut-hb210" are both "UT:HB210"). + */ +export function researchBillKeys(row: Pick): string[] { + const parts = row.id.split('-'); + if (parts.length < 2) { + return []; + } + const fromTitle = row.title?.match(TITLE_BILL_NUMBER)?.[1]; + const keys = [ + ...(fromTitle ? [processedBillKey(parts[0], fromTitle)] : []), + processedBillKey(parts[0], parts.slice(1).join('')), + ]; + return [...new Set(keys)]; +} + +/** + * The bill a report URL names, by the tracker's id. The tracker links + * here by that id, so an encoded or upper-case id finds the same bill. + */ +export function findTrackedBill( + bills: T[], + billId: string +): T | undefined { + const exact = bills.find((bill) => bill.id === billId); + if (exact) { + return exact; + } + const wanted = comparableId(billId); + return bills.find((bill) => comparableId(bill.id) === wanted); +} diff --git a/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts b/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts new file mode 100644 index 000000000..246a20937 --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts @@ -0,0 +1,27 @@ +import type { TrackerResearchRow } from '@/libs/flagship/billTracker'; + +/** policyengine.org's mount of the tracker, as tests read it (no VITE_WEBSITE_URL). */ +export const TRACKER_HOME = 'https://policyengine.org/us/bill-tracker'; + +/** Tracker research rows, one per routing case. */ +export const TRACKER_ROWS = { + STATE_BILL: { id: 'ks-hb2620', state: 'KS', type: 'bill', title: 'KS HB 2620' }, + FEDERAL_BILL: { id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }, + /** An older federal row filed under "all": the tracker links it under /us by its id. */ + LEGACY_FEDERAL_BILL: { id: 'us-hr7024', state: 'all', type: 'bill', title: 'HR 7024' }, + /** An older federal row with no bill id, which the tracker gives no page. */ + LEGACY_FEDERAL: { id: 'ctc-2026', state: 'all', type: 'bill', title: 'CTC expansion' }, + /** The tracker lists only rows typed "bill" as bills. */ + UNTYPED_STATE_ROW: { id: 'ut-sb60', state: 'UT', title: 'UT SB60' }, + STATE_BLOG: { id: 'la-flat-tax', state: 'LA', type: 'blog', title: 'Louisiana flat tax' }, + IN_REVIEW: { id: 'ga-hb111', state: 'GA', type: 'bill', status: 'in_review', title: 'GA HB 111' }, +} satisfies Record; + +export const STATE_NAMES_SAMPLE: Record = { + DC: 'District of Columbia', + KS: 'Kansas', + UT: 'Utah', +}; + +/** Tracked bills as the feed lists them, for id lookups. */ +export const FEED_IDS = [{ id: 'ks-hb2620' }, { id: 'us-hr1425' }]; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index b0f0d362e..014d0d5c2 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -513,4 +513,69 @@ describe('fetchTrackerBills', () => { expect(bills![0].validation?.withinRange).toBe(true); expect(bills![0].validation?.drift).toBeUndefined(); }); + + test('given tracker rows then each bill carries the tracker place, name, stage and page', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const responses: Record = { + research: [ + { id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }, + { id: 'dc-b26', state: 'dc', type: 'bill', title: 'DC B26' }, + // The tracker writes the bill number "HB 0210"; the id runs it together. + { id: 'ut-hb210', state: 'UT', type: 'bill', title: 'UT HB 210' }, + // A dashed id with no title, and a processed row with no stage yet. + { id: 'ut-hb-106', state: 'UT', type: 'bill' }, + // The id carries the session year; the title carries the bill number. + { id: 'ga-hb463-2026', state: 'GA', type: 'bill', title: 'GA HB 463: income tax cut' }, + ], + processed_bills: [ + { state: 'US', bill_number: 'HR 1425', status: 'In Committee' }, + { state: 'UT', bill_number: 'HB 0210', status: 'Signed into Law' }, + { state: 'UT', bill_number: 'HB 106', status: null }, + { state: 'GA', bill_number: 'HB 463', status: 'Passed One Chamber' }, + ], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const table = String(url).match(/rest\/v1\/(\w+)\?/)?.[1] ?? ''; + return { ok: true, json: async () => responses[table] ?? [] }; + }) + ); + + const bills = await fetchTrackerBills(); + + const tracker = 'https://policyengine.org/us/bill-tracker'; + expect( + bills!.map((bill) => [bill.jurisdiction, bill.title, bill.status, bill.trackerUrl]) + ).toEqual([ + ['Federal', 'HR 1425', 'In Committee', `${tracker}/us/us-hr1425`], + ['District of Columbia', 'DC B26', 'Analyzed', `${tracker}/dc/dc-b26`], + ['Utah', 'UT HB 210', 'Signed into Law', `${tracker}/ut/ut-hb210`], + ['Utah', 'HB-106', 'Analyzed', `${tracker}/ut/ut-hb-106`], + ['Georgia', 'GA HB 463: income tax cut', 'Passed One Chamber', `${tracker}/ga/ga-hb463-2026`], + ]); + }); + + test('given only a net cost then it reads as the revenue change, as in the tracker', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const responses: Record = { + research: [{ id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }], + reform_impacts: [ + { id: 'us-hr1425', computed: true, budgetary_impact: { netCost: -74_200_000 } }, + ], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const table = String(url).match(/rest\/v1\/(\w+)\?/)?.[1] ?? ''; + return { ok: true, json: async () => responses[table] ?? [] }; + }) + ); + + const bills = await fetchTrackerBills(); + + expect(bills![0].impacts?.revenue).toBe(-74_200_000); + }); }); diff --git a/app/src/tests/unit/libs/flagship/billTracker.test.ts b/app/src/tests/unit/libs/flagship/billTracker.test.ts new file mode 100644 index 000000000..64672ece5 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/billTracker.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'vitest'; +import { + BILL_TRACKER_URL, + findTrackedBill, + processedBillKey, + researchBillKeys, + trackerBillTitle, + trackerBillUrl, + trackerPlace, + trackerUrlForBillId, +} from '@/libs/flagship/billTracker'; +import { + FEED_IDS, + STATE_NAMES_SAMPLE, + TRACKER_HOME, + TRACKER_ROWS, +} from '@/tests/fixtures/libs/flagship/billTrackerMocks'; + +describe('trackerBillUrl', () => { + test('given the tracker mount then the home is policyengine.org/us/bill-tracker', () => { + expect(BILL_TRACKER_URL).toBe(TRACKER_HOME); + }); + + test('given a state bill then it links to /{state}/{id}, as the tracker routes it', () => { + expect(trackerBillUrl(TRACKER_ROWS.STATE_BILL)).toBe(`${TRACKER_HOME}/ks/ks-hb2620`); + }); + + test('given a federal bill then it links under /us', () => { + expect(trackerBillUrl(TRACKER_ROWS.FEDERAL_BILL)).toBe(`${TRACKER_HOME}/us/us-hr1425`); + expect(trackerBillUrl(TRACKER_ROWS.LEGACY_FEDERAL_BILL)).toBe(`${TRACKER_HOME}/us/us-hr7024`); + }); + + test('given a row with no bill page then it links to the state page or the home', () => { + expect(trackerBillUrl(TRACKER_ROWS.UNTYPED_STATE_ROW)).toBe(`${TRACKER_HOME}/ut`); + expect(trackerBillUrl(TRACKER_ROWS.STATE_BLOG)).toBe(`${TRACKER_HOME}/la`); + expect(trackerBillUrl(TRACKER_ROWS.IN_REVIEW)).toBe(`${TRACKER_HOME}/ga`); + expect(trackerBillUrl(TRACKER_ROWS.LEGACY_FEDERAL)).toBe(TRACKER_HOME); + expect(trackerBillUrl({ id: 'no-state', type: 'bill' })).toBe(TRACKER_HOME); + }); + + test('given an id with URL characters then the id is encoded', () => { + expect(trackerBillUrl({ id: 'sc-h 3492', state: 'SC', type: 'bill' })).toBe( + `${TRACKER_HOME}/sc/sc-h%203492` + ); + }); +}); + +describe('trackerUrlForBillId', () => { + test('given only an id then the place comes from its prefix', () => { + expect(trackerUrlForBillId('ks-hb2620')).toBe(`${TRACKER_HOME}/ks/ks-hb2620`); + expect(trackerUrlForBillId('US-HR1425')).toBe(`${TRACKER_HOME}/us/us-hr1425`); + expect(trackerUrlForBillId('obbba-explorer')).toBe(TRACKER_HOME); + }); +}); + +describe('trackerPlace', () => { + test('given a state code in any case then it reads as the state name', () => { + expect(trackerPlace('KS', STATE_NAMES_SAMPLE)).toBe('Kansas'); + expect(trackerPlace('dc', STATE_NAMES_SAMPLE)).toBe('District of Columbia'); + }); + + test('given the tracker federal codes or none then it reads "Federal"', () => { + for (const code of ['US', 'all', 'federal', '', undefined, null]) { + expect(trackerPlace(code, STATE_NAMES_SAMPLE)).toBe('Federal'); + } + }); + + test('given an unknown code then it stays as stored', () => { + expect(trackerPlace('PR', STATE_NAMES_SAMPLE)).toBe('PR'); + }); +}); + +describe('trackerBillTitle', () => { + test('given a title then it is the name', () => { + expect(trackerBillTitle({ id: 'ut-sb60', title: ' UT SB60: rate cut ' })).toBe( + 'UT SB60: rate cut' + ); + }); + + test('given no title then the name is the bill number the id carries', () => { + expect(trackerBillTitle({ id: 'ut-sb60', title: null })).toBe('SB60'); + expect(trackerBillTitle({ id: 'ga-hb463-2026' })).toBe('HB463-2026'); + expect(trackerBillTitle({ id: 'budget2026' })).toBe('BUDGET2026'); + }); +}); + +describe('processed bill keys', () => { + test('given spacing, dashes and leading zeros then a bill and its row share a key', () => { + expect(processedBillKey('UT', 'HB 0210')).toBe('UT:HB210'); + expect(researchBillKeys({ id: 'ut-hb210' })).toEqual(['UT:HB210']); + expect(researchBillKeys({ id: 'ut-hb-210' })).toEqual(['UT:HB210']); + expect(researchBillKeys({ id: 'us-hr1425' })).toEqual([processedBillKey('us', 'HR 1425')]); + }); + + test('given a bill number in the title then that key comes first', () => { + expect(researchBillKeys({ id: 'ga-hb463-2026', title: 'GA HB 463: income tax cut' })).toEqual([ + 'GA:HB463', + 'GA:HB4632026', + ]); + expect(researchBillKeys({ id: 'sc-h3492', title: 'SC H.3492' })).toEqual(['SC:H3492']); + }); + + test('given an id with no bill number then it has no key', () => { + expect(researchBillKeys({ id: 'budget2026', title: 'FY2026 budget' })).toEqual([]); + }); +}); + +describe('findTrackedBill', () => { + test('given the tracker id then it finds the bill', () => { + expect(findTrackedBill(FEED_IDS, 'ks-hb2620')).toBe(FEED_IDS[0]); + }); + + test('given the id upper-case or encoded then it finds the same bill', () => { + expect(findTrackedBill(FEED_IDS, 'KS-HB2620')).toBe(FEED_IDS[0]); + expect(findTrackedBill(FEED_IDS, 'us%2Dhr1425')).toBe(FEED_IDS[1]); + }); + + test('given an unknown or malformed id then it finds nothing', () => { + expect(findTrackedBill(FEED_IDS, 'ks-hb9999')).toBeUndefined(); + expect(findTrackedBill(FEED_IDS, '%E0%A4%A')).toBeUndefined(); + }); +}); From eea72d942a76abfdd6d4f1c6a82ad64e48a98f6f Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:54:09 -0400 Subject: [PATCH 06/27] Report: bill report links to its tracker page The overview lead links to the bill's page in the bill tracker, beside the bill text. The report URL stays keyed by the tracker's bill id; an upper-case or encoded id opens the same report, and a bill not in the feed links to that bill in the tracker. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/BillTrackerLinks.tsx | 36 +++++++++++++++++++ app/src/pages/flagship/BillReport.page.tsx | 14 ++++++-- .../libs/flagship/trackedBillMocks.ts | 1 + .../flagship/BillTrackerLinks.test.tsx | 23 ++++++++++++ .../unit/pages/flagship/BillReport.test.tsx | 27 ++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 app/src/components/flagship/BillTrackerLinks.tsx create mode 100644 app/src/tests/unit/components/flagship/BillTrackerLinks.test.tsx diff --git a/app/src/components/flagship/BillTrackerLinks.tsx b/app/src/components/flagship/BillTrackerLinks.tsx new file mode 100644 index 000000000..fb60acc1d --- /dev/null +++ b/app/src/components/flagship/BillTrackerLinks.tsx @@ -0,0 +1,36 @@ +import { IconExternalLink } from '@tabler/icons-react'; +import { Button } from '@/components/ui'; +import { colors, typography } from '@/designTokens'; +import { BILL_TRACKER_URL } from '@/libs/flagship/billTracker'; + +/** A bill's page in the bill tracker, beside the report's other source links. */ +export function BillTrackerLink({ href }: { href: string }) { + return ( + + Bill tracker + + + ); +} + +/** The bill tracker's home, where every tracked bill is listed. */ +export function BillTrackerHomeButton() { + return ( + + ); +} diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 3987468b7..6c2054484 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -3,6 +3,7 @@ import { IconCalendar, IconExternalLink } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { billRunYear, type TrackedBill } from '@/api/billFeed'; +import { BillTrackerLink } from '@/components/flagship/BillTrackerLinks'; import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import DistrictImpactCard, { type CongressionalMode, @@ -21,6 +22,7 @@ import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; +import { findTrackedBill, trackerUrlForBillId } from '@/libs/flagship/billTracker'; import { RootState } from '@/store'; interface BillReportPageProps { @@ -43,7 +45,10 @@ function Caption({ children }: { children: React.ReactNode }) { ); } -/** The bill's summary, analysis date, and bill text. Its validation verdict lives in Validation. */ +/** + * The bill's summary, analysis date, bill text, and tracker page. Its + * validation verdict lives in Validation. + */ function BillOverviewLead({ bill }: { bill: TrackedBill }) { const billTextUrl = bill.sourceUrl ?? bill.legiscanUrl; return ( @@ -59,7 +64,7 @@ function BillOverviewLead({ bill }: { bill: TrackedBill }) { {bill.summary} )} - {(bill.date || billTextUrl) && ( + {(bill.date || billTextUrl || bill.trackerUrl) && ( @@ -87,6 +92,7 @@ function BillOverviewLead({ bill }: { bill: TrackedBill }) { )} + {bill.trackerUrl && } )} @@ -225,7 +231,8 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) const [runRequestedFor, setRunRequestedFor] = useState(null); const runRequested = runRequestedFor === billId; - const bill: TrackedBill | undefined = bills.find((candidate) => candidate.id === billId); + // The tracker links here by its bill id; an encoded or upper-case id finds the same bill. + const bill: TrackedBill | undefined = findTrackedBill(bills, billId); const { data: storedDistricts } = useStoredBillDistricts(bill?.id); // A bill from a later year runs, and reads against the law, in its first year. @@ -268,6 +275,7 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) {isLoading ? 'Loading the analysis…' : 'This bill is not in the current feed.'} + {!isLoading && billId && } ); } diff --git a/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts b/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts index 7c585617d..3170496e5 100644 --- a/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts @@ -18,6 +18,7 @@ export const TRACKED_BILL: TrackedBill = { provisions: [{ path: 'gov.irs.credits.ctc.amount.base[0].amount', value: 5000 }], keyFindings: ['External check (cost): within CRFB band.'], sourceUrl: 'https://www.congress.gov/bill/119th-congress/house-bill/1425', + trackerUrl: 'https://policyengine.org/us/bill-tracker/us/us-hr1425', date: '2026-07-06', provenance: { modelVersion: '1.729.3', diff --git a/app/src/tests/unit/components/flagship/BillTrackerLinks.test.tsx b/app/src/tests/unit/components/flagship/BillTrackerLinks.test.tsx new file mode 100644 index 000000000..c942947d6 --- /dev/null +++ b/app/src/tests/unit/components/flagship/BillTrackerLinks.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import { BillTrackerHomeButton, BillTrackerLink } from '@/components/flagship/BillTrackerLinks'; +import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; + +describe('BillTrackerLinks', () => { + test('given a tracker page then the link opens it in a new tab', () => { + render(); + + const link = screen.getByRole('link', { name: 'Bill tracker' }); + expect(link).toHaveAttribute('href', `${TRACKER_HOME}/ks/ks-hb2620`); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer'); + }); + + test('given the home button then it links to the tracker home in a new tab', () => { + render(); + + const link = screen.getByRole('link', { name: 'Bill tracker' }); + expect(link).toHaveAttribute('href', TRACKER_HOME); + expect(link).toHaveAttribute('target', '_blank'); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index 52e6bcb69..30eb81c23 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -165,6 +165,33 @@ describe('BillReportPage', () => { ).toBeGreaterThanOrEqual(1); }); + test('given a tracked bill then the overview links to its tracker page', async () => { + renderReport(); + + const link = await screen.findByRole('link', { name: /bill tracker/i }); + expect(link).toHaveAttribute('href', TRACKED_BILL.trackerUrl); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('given the tracker id in upper case then the same report opens', async () => { + renderReport('US-HR1425'); + + expect( + await screen.findByRole('heading', { level: 1, name: TRACKED_BILL.title }) + ).toBeInTheDocument(); + }); + + test('given a bill not in the feed then it links to that bill in the tracker', async () => { + mockFetchTrackerBills.mockResolvedValue([]); + renderReport('ks-hb2620'); + + expect(await screen.findByText('This bill is not in the current feed.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /bill tracker/i })).toHaveAttribute( + 'href', + 'https://policyengine.org/us/bill-tracker/ks/ks-hb2620' + ); + }); + test('given the full run is calculating then economic impacts show its progress', async () => { const user = userEvent.setup(); mockUseBillEconomy.mockReturnValue(economy({ message: 'In queue (position 2)...' })); From ee60cdf40731ebf0b1cd3fba0a546f82766fdd27 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:54:13 -0400 Subject: [PATCH 07/27] Reforms: link the bill tracker home US reforms show a "Bill tracker" button beside "New reform" that opens the tracker's home in a new tab. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/flagship/Reforms.page.tsx | 12 ++++++---- .../unit/pages/flagship/Reforms.test.tsx | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4542b6c02..96f594300 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -16,6 +16,7 @@ import { useSelector } from 'react-redux'; import { billRunYear, TrackedBill } from '@/api/billFeed'; import { getFlagshipReportStore } from '@/api/flagshipReportStore'; import { getReformStore } from '@/api/reformStore'; +import { BillTrackerHomeButton } from '@/components/flagship/BillTrackerLinks'; import ProvisionList from '@/components/flagship/ProvisionList'; import ReformReports from '@/components/flagship/ReformReports'; import ScopePicker from '@/components/flagship/ScopePicker'; @@ -864,10 +865,13 @@ export default function ReformsPage() { Reforms - + + {countryId === 'us' && } + + diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index 7722646d1..659ebf0cf 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -193,6 +193,28 @@ describe('ReformsPage', () => { expect(screen.queryByRole('button', { name: /open as draft reform/i })).not.toBeInTheDocument(); }); + test('given US reforms then the header links to the bill tracker home', async () => { + renderReforms(); + + const link = await screen.findByRole('link', { name: /bill tracker/i }); + expect(link).toHaveAttribute('href', 'https://policyengine.org/us/bill-tracker'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('given UK reforms then the header has no bill tracker link', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderWithCountry( + + + , + 'uk', + '/uk/reforms' + ); + + expect(await screen.findByRole('heading', { level: 1, name: 'Reforms' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /bill tracker/i })).not.toBeInTheDocument(); + }); + test('given a bill opens then no place-and-status line sits over its title', async () => { const user = userEvent.setup(); renderReforms(); From d15ca4c4f3d00efd5e0575c9c9c9e2c03e20286b Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:54:16 -0400 Subject: [PATCH 08/27] Region estimates: opt-in for v3 only The region estimate and the 110 s UK value applied to every society-wide run, so legacy v2 loading bars filled against new durations. The country estimates are back as on main (UK 3 minutes, US 6 minutes), and getDurationForCountry, getDurationForCalcType and the strategy's default output match main again. The region estimate is now opt-in: CalcParams.estimateByRegion (passed through CalcStartConfig and the orchestrator) makes the strategy use getDurationForRegion and return estimatedDurationMs. Only the flagship report run and useBillEconomy set it. The UK region value is a new constant that only getDurationForRegion uses. Also: getDurationForRegion classifies legacy US codes (ca, us-ca, CA-12) through normalizeRegionCode, and useSyntheticProgress syncs the estimate ref in an effect instead of during render. Tests: the default strategy output for UK and US state/district runs keeps 3 and 6 minutes with no estimate attached; the opt-in path per region; the orchestrator leaves params unchanged by default; the status hooks pass the estimate to useSyntheticProgress (the aggregate uses the longest pending one); the v3 callers opt in. Co-Authored-By: Claude Opus 5.5 --- app/src/constants/calculationDurations.ts | 54 ++++--- app/src/hooks/useBillEconomy.ts | 1 + app/src/hooks/useSyntheticProgress.ts | 4 +- app/src/libs/calculations/CalcOrchestrator.ts | 2 + .../economy/SocietyWideCalcStrategy.ts | 38 +++-- app/src/pages/flagship/Report.page.tsx | 2 + .../fixtures/hooks/calculationHookFixtures.ts | 8 + .../constants/calculationDurations.test.ts | 40 +++-- .../useAggregatedCalculationStatus.test.tsx | 92 ++++++++++- .../tests/unit/hooks/useBillEconomy.test.tsx | 15 ++ .../unit/hooks/useCalculationStatus.test.tsx | 65 ++++++++ .../calculations/CalcOrchestrator.test.ts | 29 ++++ .../SocietyWideCalcStrategy.test.ts | 147 +++++++++++------- .../tests/unit/pages/flagship/Report.test.tsx | 22 ++- app/src/types/calculation/CalcParams.ts | 8 + app/src/types/calculation/CalcStartConfig.ts | 6 + app/src/types/calculation/CalcStatus.ts | 3 +- 17 files changed, 435 insertions(+), 101 deletions(-) diff --git a/app/src/constants/calculationDurations.ts b/app/src/constants/calculationDurations.ts index fc2de99d2..b7a108042 100644 --- a/app/src/constants/calculationDurations.ts +++ b/app/src/constants/calculationDurations.ts @@ -11,38 +11,51 @@ * - Queue position * - Server load * - Calculation complexity + * - Dataset size (Enhanced CPS vs standard) * - Region (a state run is much shorter than a nationwide run) * - * Society-wide values come from successful production runs between - * 6 and 13 October 2026, measured from submission to completion. Most use - * the 90th percentile, so the bar rarely reaches its cap before the result. + * The country estimates (getDurationForCountry) are the defaults. The region + * estimates (getDurationForRegion) are opt-in, and come from successful + * production runs between 6 and 13 October 2026, measured from submission + * to completion. Most use the 90th percentile, so the bar rarely reaches its + * cap before the result. */ +import { normalizeRegionCode } from '@/models/region'; + /** - * Duration estimate for US nationwide society-wide calculations - * Observed mean 338 s, 90th percentile 392 s; 6 minutes sits between them. + * Duration estimate for US society-wide calculations + * Used for Enhanced CPS dataset calculations + * Also the region estimate for US nationwide runs: observed mean 338 s, + * 90th percentile 392 s; 6 minutes sits between them. */ export const US_SOCIETY_WIDE_DURATION_MS = 6 * 60 * 1000; // 6 minutes /** - * Duration estimate for US state society-wide calculations + * Duration estimate for UK society-wide calculations + */ +export const UK_SOCIETY_WIDE_DURATION_MS = 3 * 60 * 1000; // 3 minutes + +/** + * Region estimate for US state society-wide calculations * Observed 90th percentile 187 s. Also used for US place (city) runs, * which have no observed samples yet. */ export const US_STATE_SOCIETY_WIDE_DURATION_MS = 190 * 1000; /** - * Duration estimate for US congressional district society-wide calculations + * Region estimate for US congressional district society-wide calculations * Only one observed sample (200 s), so treat it as a rough value. */ export const US_DISTRICT_SOCIETY_WIDE_DURATION_MS = 200 * 1000; /** - * Duration estimate for UK society-wide calculations + * Region estimate for UK society-wide calculations * Observed nationwide 90th percentile 108 s. Also used for UK subnational - * runs, which have no observed samples yet. + * runs, which have no observed samples yet. Only getDurationForRegion uses + * it; the country estimate stays UK_SOCIETY_WIDE_DURATION_MS. */ -export const UK_SOCIETY_WIDE_DURATION_MS = 110 * 1000; +export const UK_REGION_SOCIETY_WIDE_DURATION_MS = 110 * 1000; /** * Duration estimate for household calculations @@ -57,7 +70,7 @@ export const HOUSEHOLD_DURATION_MS = 60 * 1000; // 1 minute export const DEFAULT_SOCIETY_WIDE_DURATION_MS = US_SOCIETY_WIDE_DURATION_MS; /** - * Get the nationwide duration estimate for a country + * Get duration estimate based on country ID * @param countryId - Country identifier ('us', 'uk', etc.) * @returns Duration in milliseconds */ @@ -73,15 +86,20 @@ export function getDurationForCountry(countryId: string): number { } /** - * Get the duration estimate for a society-wide calculation over a region + * Get the region estimate for a society-wide calculation * @param countryId - Country identifier ('us', 'uk', etc.) * @param region - Region as sent to the API, e.g. 'us', 'state/ca' or - * 'congressional_district/CA-46'; omitted or the country code for nationwide + * 'congressional_district/CA-46' (legacy codes such as 'ca', 'us-ca' and + * 'CA-46' also work); omitted or the country code for nationwide * @returns Duration in milliseconds */ export function getDurationForRegion(countryId: string, region?: string): number { - if (countryId.toLowerCase() === 'us') { - const regionType = region?.toLowerCase().split('/')[0]; + const country = countryId.toLowerCase(); + if (country === 'uk') { + return UK_REGION_SOCIETY_WIDE_DURATION_MS; + } + if (country === 'us' && region) { + const regionType = normalizeRegionCode('us', region).split('/')[0].toLowerCase(); if (regionType === 'state' || regionType === 'place') { return US_STATE_SOCIETY_WIDE_DURATION_MS; } @@ -96,16 +114,14 @@ export function getDurationForRegion(countryId: string, region?: string): number * Get duration estimate based on calculation type * @param calcType - Type of calculation ('household' or 'societyWide') * @param countryId - Optional country identifier for society-wide calculations - * @param region - Optional region for society-wide calculations * @returns Duration in milliseconds */ export function getDurationForCalcType( calcType: 'household' | 'societyWide', - countryId?: string, - region?: string + countryId?: string ): number { if (calcType === 'household') { return HOUSEHOLD_DURATION_MS; } - return countryId ? getDurationForRegion(countryId, region) : DEFAULT_SOCIETY_WIDE_DURATION_MS; + return countryId ? getDurationForCountry(countryId) : DEFAULT_SOCIETY_WIDE_DURATION_MS; } diff --git a/app/src/hooks/useBillEconomy.ts b/app/src/hooks/useBillEconomy.ts index a8c37f42d..014c969fd 100644 --- a/app/src/hooks/useBillEconomy.ts +++ b/app/src/hooks/useBillEconomy.ts @@ -89,6 +89,7 @@ export function useBillEconomy( region, year, calcId: metadata.calcId, + estimateByRegion: true, }, metadata ), diff --git a/app/src/hooks/useSyntheticProgress.ts b/app/src/hooks/useSyntheticProgress.ts index 713fe923e..4029bcdd5 100644 --- a/app/src/hooks/useSyntheticProgress.ts +++ b/app/src/hooks/useSyntheticProgress.ts @@ -121,7 +121,9 @@ export function useSyntheticProgress( // Read through a ref: the estimate arrives with the first server status, // and a dependency on it would restart the timer from zero. const estimatedDurationRef = useRef(serverProgress?.estimatedDurationMs); - estimatedDurationRef.current = serverProgress?.estimatedDurationMs; + useEffect(() => { + estimatedDurationRef.current = serverProgress?.estimatedDurationMs; + }, [serverProgress?.estimatedDurationMs]); useEffect(() => { if (!isActive) { diff --git a/app/src/libs/calculations/CalcOrchestrator.ts b/app/src/libs/calculations/CalcOrchestrator.ts index 6295f48b9..07efc0654 100644 --- a/app/src/libs/calculations/CalcOrchestrator.ts +++ b/app/src/libs/calculations/CalcOrchestrator.ts @@ -263,6 +263,8 @@ export class CalcOrchestrator { region, year: config.year, calcId: config.calcId, + // Opt-in (v3): without it the params stay exactly as before. + ...(config.estimateByRegion && { estimateByRegion: true }), }; } } diff --git a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts index f4e663c78..324fd88e1 100644 --- a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts +++ b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts @@ -4,10 +4,18 @@ import { SocietyWideCalculationParams, SocietyWideCalculationResponse, } from '@/api/societyWideCalculation'; -import { getDurationForRegion } from '@/constants/calculationDurations'; +import { getDurationForCountry, getDurationForRegion } from '@/constants/calculationDurations'; import { CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; import { CalcExecutionStrategy, RefetchConfig } from '../strategies/types'; +/** How a caller wants the run's duration estimated. */ +export interface DurationEstimateOptions { + /** Estimate from the region (v3). Off by default: the country estimate applies. */ + estimateByRegion?: boolean; + /** The region as sent to the API, e.g. 'state/ca'; read only with estimateByRegion. */ + region?: string; +} + /** * Strategy for executing society-wide calculations * Uses server-side polling with checkpointed state @@ -40,12 +48,10 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { ); // Transform to unified status with provided metadata - return this.transformResponseWithMetadata( - response, - metadata, - params.countryId, - params.region - ); + return this.transformResponseWithMetadata(response, metadata, params.countryId, { + estimateByRegion: params.estimateByRegion, + region: apiRegion, + }); } catch (error) { console.error('[SocietyWideCalcStrategy.execute] Calculation failed:', error); const errorMessage = @@ -80,12 +86,15 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { /** * Transform society-wide API response with provided metadata + * @param estimate - Opt-in (v3): with estimateByRegion, progress follows the + * region estimate and pending statuses carry estimatedDurationMs. Without + * it, the country estimate applies and no estimatedDurationMs is set. */ transformResponseWithMetadata( apiResponse: unknown, metadata: CalcMetadata, countryId?: string, - region?: string + estimate: DurationEstimateOptions = {} ): CalcStatus { const response = apiResponse as SocietyWideCalculationResponse; @@ -93,13 +102,13 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { if (response.status === 'computing') { // Calculate synthetic progress based on elapsed time (mimicking household calculations) const elapsed = Date.now() - metadata.startedAt; - const estimatedDuration = this.getEstimatedDuration(countryId, region); + const estimatedDuration = this.getEstimatedDuration(countryId, estimate); const progress = Math.min((elapsed / estimatedDuration) * 100, 95); // Cap at 95% return { status: 'pending', progress, - estimatedDurationMs: estimatedDuration, + ...(estimate.estimateByRegion && { estimatedDurationMs: estimatedDuration }), queuePosition: response.queue_position, message: this.getComputingMessage(response.queue_position), metadata, @@ -151,10 +160,13 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { } /** - * Get estimated duration for society-wide calculations based on country and region + * Get estimated duration for society-wide calculations based on country, + * or on country and region when the caller opts in * Uses shared constants from calculationDurations */ - private getEstimatedDuration(countryId?: string, region?: string): number { - return getDurationForRegion(countryId || 'us', region); + private getEstimatedDuration(countryId?: string, estimate: DurationEstimateOptions = {}): number { + return estimate.estimateByRegion + ? getDurationForRegion(countryId || 'us', estimate.region) + : getDurationForCountry(countryId || 'us'); } } diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 93fe14623..583e95c13 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -79,6 +79,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep countryId: report.countryId, year: report.year, simulations: { simulation1, simulation2: simulations[1] || null }, + // The bar follows a typical run for this region, not the country. + estimateByRegion: true, populations: { household1: null, household2: null, diff --git a/app/src/tests/fixtures/hooks/calculationHookFixtures.ts b/app/src/tests/fixtures/hooks/calculationHookFixtures.ts index 89e55cfd3..7aa607de3 100644 --- a/app/src/tests/fixtures/hooks/calculationHookFixtures.ts +++ b/app/src/tests/fixtures/hooks/calculationHookFixtures.ts @@ -12,6 +12,14 @@ export const HOOK_TEST_CONSTANTS = { TEST_USER_REPORT_ID: 'user-report-789', } as const; +/** + * Region estimates a pending society-wide status can carry (v3 runs opt in) + */ +export const TEST_ESTIMATED_DURATIONS_MS = { + US_STATE: 190_000, + US_DISTRICT: 200_000, +} as const; + /** * Create a real QueryClient for hook tests * This is needed because hooks require a real QueryClient instance diff --git a/app/src/tests/unit/constants/calculationDurations.test.ts b/app/src/tests/unit/constants/calculationDurations.test.ts index 79cae4346..eff679ef7 100644 --- a/app/src/tests/unit/constants/calculationDurations.test.ts +++ b/app/src/tests/unit/constants/calculationDurations.test.ts @@ -4,13 +4,27 @@ import { getDurationForCountry, getDurationForRegion, HOUSEHOLD_DURATION_MS, - UK_SOCIETY_WIDE_DURATION_MS, + UK_REGION_SOCIETY_WIDE_DURATION_MS, US_DISTRICT_SOCIETY_WIDE_DURATION_MS, US_SOCIETY_WIDE_DURATION_MS, US_STATE_SOCIETY_WIDE_DURATION_MS, } from '@/constants/calculationDurations'; +// The country estimates that legacy (v2) progress bars use +const THREE_MINUTES_MS = 3 * 60 * 1000; +const SIX_MINUTES_MS = 6 * 60 * 1000; + describe('calculationDurations', () => { + describe('getDurationForCountry', () => { + it.each([ + ['us', SIX_MINUTES_MS], + ['uk', THREE_MINUTES_MS], + ['ca', SIX_MINUTES_MS], + ])('given %s then keeps the %i ms country estimate', (countryId, expected) => { + expect(getDurationForCountry(countryId)).toBe(expected); + }); + }); + describe('getDurationForRegion', () => { it.each([ ['us', undefined, US_SOCIETY_WIDE_DURATION_MS], @@ -18,13 +32,23 @@ describe('calculationDurations', () => { ['us', 'state/ca', US_STATE_SOCIETY_WIDE_DURATION_MS], ['us', 'place/CA-57000', US_STATE_SOCIETY_WIDE_DURATION_MS], ['us', 'congressional_district/CA-46', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], - ['uk', 'uk', UK_SOCIETY_WIDE_DURATION_MS], - ['uk', 'constituency/Sheffield Central', UK_SOCIETY_WIDE_DURATION_MS], - ['uk', 'country/england', UK_SOCIETY_WIDE_DURATION_MS], + ['uk', undefined, UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'uk', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'constituency/Sheffield Central', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'country/england', UK_REGION_SOCIETY_WIDE_DURATION_MS], ])('given %s and region %s then returns %i ms', (countryId, region, expected) => { expect(getDurationForRegion(countryId, region)).toBe(expected); }); + it.each([ + ['ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us-ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['CA-12', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ['us-ca-12', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ])('given the legacy US region code %s then returns %i ms', (region, expected) => { + expect(getDurationForRegion('us', region)).toBe(expected); + }); + it('given a US state then the estimate is shorter than a nationwide run', () => { expect(getDurationForRegion('us', 'state/ny')).toBeLessThan(getDurationForRegion('us')); }); @@ -36,13 +60,11 @@ describe('calculationDurations', () => { describe('getDurationForCalcType', () => { it('given household then returns the household duration', () => { - expect(getDurationForCalcType('household', 'us', 'state/ca')).toBe(HOUSEHOLD_DURATION_MS); + expect(getDurationForCalcType('household', 'us')).toBe(HOUSEHOLD_DURATION_MS); }); - it('given society-wide with a region then returns the region duration', () => { - expect(getDurationForCalcType('societyWide', 'us', 'state/ca')).toBe( - US_STATE_SOCIETY_WIDE_DURATION_MS - ); + it('given society-wide then keeps the country estimate', () => { + expect(getDurationForCalcType('societyWide', 'uk')).toBe(THREE_MINUTES_MS); }); }); }); diff --git a/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx b/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx index 89e1700f5..dfde07604 100644 --- a/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx +++ b/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx @@ -3,10 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAggregatedCalculationStatus } from '@/hooks/useAggregatedCalculationStatus'; +import { useSyntheticProgress } from '@/hooks/useSyntheticProgress'; import { calculationKeys } from '@/libs/queryKeys'; -import { createTestMetadata, TEST_SIM_IDS } from '@/tests/fixtures/hooks/aggregatedStatusMocks'; +import { + createTestMetadata, + TEST_REPORT_IDS, + TEST_SIM_IDS, +} from '@/tests/fixtures/hooks/aggregatedStatusMocks'; +import { TEST_ESTIMATED_DURATIONS_MS } from '@/tests/fixtures/hooks/calculationHookFixtures'; import { mockCalcStatusComplete, + mockCalcStatusComputing, mockCalcStatusError, mockCalcStatusIdle, mockHouseholdResult, @@ -278,3 +285,86 @@ describe.skip('useAggregatedCalculationStatus', () => { }); }); }); + +// Outside the skipped suite above: the calculation IDs are one stable array +// and cached statuses never go stale, so the hook subscribes once and no +// observer fetches. +describe('useAggregatedCalculationStatus duration estimate', () => { + const CALC_IDS = [TEST_REPORT_IDS.REPORT_1, TEST_REPORT_IDS.REPORT_2]; + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const cachePendingRun = (calcId: string, estimatedDurationMs?: number) => { + queryClient.setQueryData( + calculationKeys.byReportId(calcId), + mockCalcStatusComputing({ + metadata: createTestMetadata(calcId, 'societyWide', 'report'), + ...(estimatedDurationMs !== undefined && { estimatedDurationMs }), + }) + ); + }; + + it('given two pending runs with different estimates then the bar follows the longer', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1, TEST_ESTIMATED_DURATIONS_MS.US_STATE); + cachePendingRun(TEST_REPORT_IDS.REPORT_2, TEST_ESTIMATED_DURATIONS_MS.US_DISTRICT); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_DISTRICT }) + ); + }); + + it('given only one pending run has an estimate then the bar follows it', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1); + cachePendingRun(TEST_REPORT_IDS.REPORT_2, TEST_ESTIMATED_DURATIONS_MS.US_STATE); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE }) + ); + }); + + it('given no pending run has an estimate then the default applies', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1); + cachePendingRun(TEST_REPORT_IDS.REPORT_2); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: undefined }) + ); + }); +}); diff --git a/app/src/tests/unit/hooks/useBillEconomy.test.tsx b/app/src/tests/unit/hooks/useBillEconomy.test.tsx index 98e9fc7cc..ffdf38ba6 100644 --- a/app/src/tests/unit/hooks/useBillEconomy.test.tsx +++ b/app/src/tests/unit/hooks/useBillEconomy.test.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; import { billRegion, useBillEconomy } from '@/hooks/useBillEconomy'; +import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const { mockCreatePolicy, mockFetchSocietyWide } = vi.hoisted(() => ({ @@ -106,6 +107,20 @@ describe('useBillEconomy', () => { expect(result.current.output).toBeNull(); }); + test('given a state bill then the run asks for the region estimate', async () => { + mockFetchSocietyWide.mockResolvedValue({ status: 'computing', result: null }); + const execute = vi.spyOn(SocietyWideCalcStrategy.prototype, 'execute'); + + renderHook(() => useBillEconomy(BILL), { wrapper }); + + await waitFor(() => expect(execute).toHaveBeenCalled()); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ region: 'state/ut', estimateByRegion: true }), + expect.anything() + ); + execute.mockRestore(); + }); + test('given the run fails then the error is reported', async () => { mockFetchSocietyWide.mockResolvedValue({ status: 'error', result: null, error: 'Worker lost' }); diff --git a/app/src/tests/unit/hooks/useCalculationStatus.test.tsx b/app/src/tests/unit/hooks/useCalculationStatus.test.tsx index 9e1ec97ee..9a9fd4d4d 100644 --- a/app/src/tests/unit/hooks/useCalculationStatus.test.tsx +++ b/app/src/tests/unit/hooks/useCalculationStatus.test.tsx @@ -4,10 +4,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useReportProgressDisplay } from '@/hooks/useReportProgressDisplay'; +import { useSyntheticProgress } from '@/hooks/useSyntheticProgress'; import { calculationKeys } from '@/libs/queryKeys'; import { createTestQueryClient, HOOK_TEST_CONSTANTS, + TEST_ESTIMATED_DURATIONS_MS, } from '@/tests/fixtures/hooks/calculationHookFixtures'; import { mockCalcStatusComplete, @@ -361,6 +363,69 @@ describe('useCalculationStatus', () => { }); }); + test('given a pending status with a region estimate then the bar follows it', async () => { + // Given + vi.mocked(useSyntheticProgress).mockClear(); + queryClient.setQueryData( + calculationKeys.byReportId(HOOK_TEST_CONSTANTS.TEST_REPORT_ID), + mockCalcStatusComputing({ + metadata: { + calcId: HOOK_TEST_CONSTANTS.TEST_REPORT_ID, + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }, + estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE, + }) + ); + + // When + renderHook(() => useCalculationStatus(HOOK_TEST_CONSTANTS.TEST_REPORT_ID, 'report'), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(useSyntheticProgress).toHaveBeenCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE }) + ); + }); + }); + + test('given a pending status without an estimate then the default applies', async () => { + // Given + vi.mocked(useSyntheticProgress).mockClear(); + queryClient.setQueryData( + calculationKeys.byReportId(HOOK_TEST_CONSTANTS.TEST_REPORT_ID), + mockCalcStatusComputing({ + metadata: { + calcId: HOOK_TEST_CONSTANTS.TEST_REPORT_ID, + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }, + }) + ); + + // When + const { result } = renderHook( + () => useCalculationStatus(HOOK_TEST_CONSTANTS.TEST_REPORT_ID, 'report'), + { wrapper } + ); + + // Then - every active call leaves the estimate unset + await waitFor(() => expect(result.current.isPending).toBe(true)); + const activeCalls = vi + .mocked(useSyntheticProgress) + .mock.calls.filter(([isActive]) => isActive); + expect(activeCalls.length).toBeGreaterThan(0); + activeCalls.forEach(([, , serverProgress]) => { + expect(serverProgress?.estimatedDurationMs).toBeUndefined(); + }); + }); + test('given household pending status then uses synthetic progress', async () => { // Given const calcStatus = mockCalcStatusComputing({ diff --git a/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts b/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts index c9ae6466b..2463caa0f 100644 --- a/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts +++ b/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts @@ -278,6 +278,35 @@ describe('CalcOrchestrator', () => { }); }); + describe('duration estimate', () => { + const startAndReadParams = async (config: ReturnType) => { + const mockQueryFn = vi.fn().mockResolvedValue(mockPendingSocietyWideStatus()); + (calculationQueries.forReport as any).mockImplementation((calcId: string) => + mockSocietyWideQueryOptions(calcId, mockQueryFn) + ); + await orchestrator.startCalculation(config); + return (calculationQueries.forReport as any).mock.calls[0][2]; + }; + + it('given the default then the params leave the region estimate out', async () => { + // When + const params = await startAndReadParams(mockSocietyWideCalcConfig()); + + // Then - the params stay exactly as before region estimates + expect(params).not.toHaveProperty('estimateByRegion'); + }); + + it('given estimateByRegion then the params pass it to the strategy', async () => { + // When + const params = await startAndReadParams( + mockSocietyWideCalcConfig({ estimateByRegion: true }) + ); + + // Then + expect(params.estimateByRegion).toBe(true); + }); + }); + describe('cleanup', () => { it('given no active polling then cleanup does nothing', () => { // When diff --git a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts index 2f4579885..e12c5cd1b 100644 --- a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts @@ -1,4 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + UK_REGION_SOCIETY_WIDE_DURATION_MS, + US_DISTRICT_SOCIETY_WIDE_DURATION_MS, + US_SOCIETY_WIDE_DURATION_MS, + US_STATE_SOCIETY_WIDE_DURATION_MS, +} from '@/constants/calculationDurations'; import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { mockSocietyWideCompleteResponse, @@ -7,6 +13,19 @@ import { STRATEGY_TEST_CONSTANTS, } from '@/tests/fixtures/libs/calculations/strategyFixtures'; import { mockSocietyWideCalcParams } from '@/tests/fixtures/types/calculationFixtures'; +import type { CalcParams } from '@/types/calculation'; + +// The country estimates that legacy (v2) progress bars use +const THREE_MINUTES_MS = 3 * 60 * 1000; +const SIX_MINUTES_MS = 6 * 60 * 1000; + +/** Metadata for a society-wide report run that started `elapsedMs` ago. */ +const startedAgo = (elapsedMs: number) => ({ + calcId: 'test-report', + calcType: 'societyWide' as const, + targetType: 'report' as const, + startedAt: Date.now() - elapsedMs, +}); // Mock the societyWide API vi.mock('@/api/societyWideCalculation', () => ({ @@ -73,7 +92,6 @@ describe('SocietyWideCalcStrategy', () => { // Then expect(result.status).toBe('pending'); expect(result.queuePosition).toBe(STRATEGY_TEST_CONSTANTS.TEST_QUEUE_POSITION); - expect(result.estimatedDurationMs).toBe(6 * 60 * 1000); expect(result.message).toContain(`position ${STRATEGY_TEST_CONSTANTS.TEST_QUEUE_POSITION}`); }); @@ -224,23 +242,82 @@ describe('SocietyWideCalcStrategy', () => { }); }); - describe('execute with a region', () => { - it('given a congressional district then estimates from the district duration', async () => { + describe('duration estimate', () => { + it.each<[CalcParams['countryId'], string, number]>([ + ['uk', 'uk', THREE_MINUTES_MS], + ['uk', 'country/england', THREE_MINUTES_MS], + ['us', 'us', SIX_MINUTES_MS], + ['us', 'state/ca', SIX_MINUTES_MS], + ['us', 'congressional_district/CA-46', SIX_MINUTES_MS], + ])( + 'given the default and a %s run on %s then progress follows the %i ms country estimate', + async (countryId, region, countryEstimateMs) => { + // Given - half of the country estimate has passed + const params = mockSocietyWideCalcParams({ countryId, region, populationId: region }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, startedAgo(countryEstimateMs / 2)); + + // Then - unchanged from before region estimates, with no estimate attached + expect(result.status).toBe('pending'); + expect(result.progress).toBeCloseTo(50, 0); + expect(result).not.toHaveProperty('estimatedDurationMs'); + } + ); + + it.each<[CalcParams['countryId'], string, number]>([ + ['uk', 'uk', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'constituency/Sheffield Central', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['us', 'us', US_SOCIETY_WIDE_DURATION_MS], + ['us', 'state/ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'congressional_district/CA-46', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ])( + 'given estimateByRegion and a %s run on %s then progress follows the %i ms region estimate', + async (countryId, region, regionEstimateMs) => { + // Given - half of the region estimate has passed + const params = mockSocietyWideCalcParams({ + countryId, + region, + populationId: region, + estimateByRegion: true, + }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, startedAgo(regionEstimateMs / 2)); + + // Then + expect(result.status).toBe('pending'); + expect(result.progress).toBeCloseTo(50, 0); + expect(result.estimatedDurationMs).toBe(regionEstimateMs); + } + ); + + it('given estimateByRegion and no region then estimates a nationwide run', async () => { // Given - const params = mockSocietyWideCalcParams({ region: 'congressional_district/CA-46' }); + const params = mockSocietyWideCalcParams({ region: undefined, estimateByRegion: true }); mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); // When - const result = await strategy.execute(params, { - calcId: 'test', - calcType: 'societyWide', - targetType: 'report', - startedAt: Date.now(), - }); + const result = await strategy.execute(params, startedAgo(0)); // Then - expect(result.status).toBe('pending'); - expect(result.estimatedDurationMs).toBe(200000); + expect(result.estimatedDurationMs).toBe(US_SOCIETY_WIDE_DURATION_MS); + }); + + it('given a region without estimateByRegion then the transform ignores the region', () => { + // When + const result = strategy.transformResponseWithMetadata( + mockSocietyWideComputingResponse(), + startedAgo(SIX_MINUTES_MS / 2), + 'us', + { region: 'state/ca' } + ); + + // Then + expect(result.progress).toBeCloseTo(50, 0); + expect(result).not.toHaveProperty('estimatedDurationMs'); }); }); @@ -362,7 +439,7 @@ describe('SocietyWideCalcStrategy', () => { calcId: 'test-report', calcType: 'societyWide' as const, targetType: 'report' as const, - startedAt: Date.now() - 55000, // Started 55 seconds ago + startedAt: Date.now() - 90000, // Started 1.5 minutes ago }; // When @@ -371,50 +448,8 @@ describe('SocietyWideCalcStrategy', () => { // Then expect(result.status).toBe('pending'); expect(result.progress).toBeDefined(); - // 55 seconds elapsed / 110 second estimate = 50% + // 1.5 minutes elapsed / 3 minute estimate = 50% expect(result.progress).toBeCloseTo(50, 0); - expect(result.estimatedDurationMs).toBe(110000); - }); - - it('given computing response for a US state then uses the state duration estimate', () => { - // Given - const apiResponse = mockSocietyWideComputingResponse(); - const metadata = { - calcId: 'test-report', - calcType: 'societyWide' as const, - targetType: 'report' as const, - startedAt: Date.now() - 95000, // Started 95 seconds ago - }; - - // When - const result = strategy.transformResponseWithMetadata( - apiResponse, - metadata, - 'us', - 'state/ca' - ); - - // Then - // 95 seconds elapsed / 190 second estimate = 50% - expect(result.progress).toBeCloseTo(50, 0); - expect(result.estimatedDurationMs).toBe(190000); - }); - - it('given computing response for a US nationwide run then keeps the 6 minute estimate', () => { - // Given - const apiResponse = mockSocietyWideComputingResponse(); - const metadata = { - calcId: 'test-report', - calcType: 'societyWide' as const, - targetType: 'report' as const, - startedAt: Date.now(), - }; - - // When - const result = strategy.transformResponseWithMetadata(apiResponse, metadata, 'us', 'us'); - - // Then - expect(result.estimatedDurationMs).toBe(6 * 60 * 1000); }); it('given computing response with unknown country then uses default duration', () => { diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 03471b685..2df805f77 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import FlagshipReportPage from '@/pages/flagship/Report.page'; const mockCalcStatus = vi.fn(); +const mockStartCalculationOnLoad = vi.fn(); vi.mock('@/hooks/useFlagshipReport', () => ({ isApiReportId: () => true, @@ -23,7 +24,7 @@ vi.mock('@/hooks/useCalculationStatus', () => ({ })); vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ - useStartCalculationOnLoad: () => undefined, + useStartCalculationOnLoad: (args: unknown) => mockStartCalculationOnLoad(args), })); vi.mock('@/hooks/useReportValidationSnapshot', () => ({ @@ -74,6 +75,25 @@ function renderPage() { describe('FlagshipReportPage', () => { beforeEach(() => { mockCalcStatus.mockReset(); + mockStartCalculationOnLoad.mockReset(); + }); + + test('given the report runs then it asks for the region estimate', () => { + mockCalcStatus.mockReturnValue({ + status: 'pending', + isComplete: false, + isError: false, + progress: 0, + result: null, + }); + + renderPage(); + + expect(mockStartCalculationOnLoad).toHaveBeenCalledWith( + expect.objectContaining({ + configs: [expect.objectContaining({ calcId: 'r1', estimateByRegion: true })], + }) + ); }); test('given the run is still calculating then the page is the wait, without the adjust panel', () => { diff --git a/app/src/types/calculation/CalcParams.ts b/app/src/types/calculation/CalcParams.ts index 572a2236b..94024c4d4 100644 --- a/app/src/types/calculation/CalcParams.ts +++ b/app/src/types/calculation/CalcParams.ts @@ -40,6 +40,14 @@ export interface CalcParams { */ region?: string; + /** + * Estimate a society-wide run's duration from its region (optional, v3) + * When true, the strategy uses getDurationForRegion and returns + * estimatedDurationMs on pending statuses. Off by default: the country + * estimate applies and no estimatedDurationMs is set. + */ + estimateByRegion?: boolean; + /** * Year for the calculation (e.g., '2025') * Used to specify which year's policy rules to apply diff --git a/app/src/types/calculation/CalcStartConfig.ts b/app/src/types/calculation/CalcStartConfig.ts index 5fc238f63..8051e25cf 100644 --- a/app/src/types/calculation/CalcStartConfig.ts +++ b/app/src/types/calculation/CalcStartConfig.ts @@ -51,4 +51,10 @@ export interface CalcStartConfig { * Used to mark the parent report as complete after all simulations finish */ reportId?: string; + + /** + * Estimate a society-wide run's duration from its region (optional, v3) + * Passed through to CalcParams.estimateByRegion. Off by default. + */ + estimateByRegion?: boolean; } diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index e9e5cefbd..c675fc1b6 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -89,7 +89,8 @@ export interface CalcStatus { /** * Typical duration of this calculation in milliseconds, for time-based - * progress. Set for pending society-wide calculations from their region. + * progress. Set only for pending society-wide calculations whose caller + * opted into the region estimate (CalcParams.estimateByRegion, v3). */ estimatedDurationMs?: number; From 8faa5fed2423ace1e801a6ef9a5e867ec233161c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:15 -0400 Subject: [PATCH 09/27] States: shared loading, empty and error states for app v3 EmptyState, ErrorState and skeleton placeholders under components/flagship/states, so every v3 surface waits, says "none yet" and fails the same way. ErrorState keeps its retry button busy while a refetch is in flight, so a second click does not stack requests. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/states/EmptyState.tsx | 77 ++++++++ .../components/flagship/states/ErrorState.tsx | 171 ++++++++++++++++++ .../components/flagship/states/Skeletons.tsx | 101 +++++++++++ app/src/components/flagship/states/index.ts | 8 + .../flagship/states/EmptyState.test.tsx | 38 ++++ .../flagship/states/ErrorState.test.tsx | 84 +++++++++ .../flagship/states/Skeletons.test.tsx | 31 ++++ 7 files changed, 510 insertions(+) create mode 100644 app/src/components/flagship/states/EmptyState.tsx create mode 100644 app/src/components/flagship/states/ErrorState.tsx create mode 100644 app/src/components/flagship/states/Skeletons.tsx create mode 100644 app/src/components/flagship/states/index.ts create mode 100644 app/src/tests/unit/components/flagship/states/EmptyState.test.tsx create mode 100644 app/src/tests/unit/components/flagship/states/ErrorState.test.tsx create mode 100644 app/src/tests/unit/components/flagship/states/Skeletons.test.tsx diff --git a/app/src/components/flagship/states/EmptyState.tsx b/app/src/components/flagship/states/EmptyState.tsx new file mode 100644 index 000000000..822877606 --- /dev/null +++ b/app/src/components/flagship/states/EmptyState.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { colors, spacing, typography } from '@/designTokens'; + +interface EmptyStateProps { + /** One short line: what there is none of. */ + title: ReactNode; + /** A second line, only when the title cannot say it alone. */ + description?: ReactNode; + /** A muted icon over the title. */ + icon?: ReactNode; + /** The next step, usually one button. */ + action?: ReactNode; + /** Inside a panel or under a search box: less padding. */ + compact?: boolean; +} + +/** + * Nothing to show yet, said in one line with the next step beside it, + * in a dashed box that holds the place of what would be there. + */ +export default function EmptyState({ + title, + description, + icon, + action, + compact = false, +}: EmptyStateProps) { + return ( +
+ {icon && ( + + {icon} + + )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {action && ( +
+ {action} +
+ )} +
+ ); +} diff --git a/app/src/components/flagship/states/ErrorState.tsx b/app/src/components/flagship/states/ErrorState.tsx new file mode 100644 index 000000000..2a8b85721 --- /dev/null +++ b/app/src/components/flagship/states/ErrorState.tsx @@ -0,0 +1,171 @@ +import { useState, type ReactNode } from 'react'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { Button } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +interface ErrorStateProps { + /** What failed, plainly: "Could not load the bill feed". */ + title: ReactNode; + /** A second line, only when it helps the reader act. */ + description?: ReactNode; + /** + * Shows "Try again". When it returns a promise (a refetch), the button + * stays busy until it settles, so a second click does not stack runs. + */ + onRetry?: () => unknown; + retryLabel?: string; + /** More buttons after the retry, e.g. back to Build. */ + actions?: ReactNode; + /** Under the buttons: a reference to hand on, technical details. */ + children?: ReactNode; + /** Inside a card that has its own heading: no box, a smaller mark. */ + compact?: boolean; +} + +const isThenable = (value: unknown): value is PromiseLike => + !!value && typeof (value as PromiseLike).then === 'function'; + +/** + * A request that failed, said plainly, with a retry where one can help. + * Every failed load in app v3 ends here rather than in a spinner that + * never stops. + */ +export default function ErrorState({ + title, + description, + onRetry, + retryLabel = 'Try again', + actions, + children, + compact = false, +}: ErrorStateProps) { + const [retrying, setRetrying] = useState(false); + + const retry = () => { + const result = onRetry?.(); + if (isThenable(result)) { + setRetrying(true); + const done = () => setRetrying(false); + result.then(done, done); + } + }; + + const buttons = (onRetry || actions) && ( +
+ {onRetry && ( + + )} + {actions} +
+ ); + + if (compact) { + return ( +
+
+ +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {buttons} + {children} +
+ ); + } + + return ( +
+
+ + + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {buttons} + {children} +
+ ); +} diff --git a/app/src/components/flagship/states/Skeletons.tsx b/app/src/components/flagship/states/Skeletons.tsx new file mode 100644 index 000000000..3224a49bb --- /dev/null +++ b/app/src/components/flagship/states/Skeletons.tsx @@ -0,0 +1,101 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Skeleton } from '@/components/ui'; +import { colors, spacing } from '@/designTokens'; + +/** + * A loading region: one live status, named for what is coming, around + * placeholder shapes laid out like the view that replaces them, so + * nothing jumps when it arrives. + */ +export function SkeletonRegion({ + label, + children, + style, +}: { + /** What is loading, e.g. "Loading bills". */ + label: string; + children: ReactNode; + style?: CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +/** One placeholder line of text or a control, at the size of what it stands for. */ +export function SkeletonLine({ + width = '100%', + height = 14, + style, +}: { + width?: CSSProperties['width']; + height?: CSSProperties['height']; + style?: CSSProperties; +}) { + return ; +} + +/** The card grid's own columns, so the placeholders fill the same tracks. */ +export const CARD_GRID_STYLE: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', + gap: spacing.md, +}; + +/** One card: eyebrow, two-line title, two-line summary, and a stats line when cards carry one. */ +function CardSkeleton({ withStats }: { withStats: boolean }) { + return ( +
+ + + + + + {withStats && ( +
+ + +
+ )} +
+ ); +} + +/** A grid of cards still on its way: the Reforms surface's bills and saved reforms. */ +export function CardGridSkeleton({ + label, + count = 6, + withStats = false, +}: { + label: string; + count?: number; + withStats?: boolean; +}) { + return ( + + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +} diff --git a/app/src/components/flagship/states/index.ts b/app/src/components/flagship/states/index.ts new file mode 100644 index 000000000..a86952205 --- /dev/null +++ b/app/src/components/flagship/states/index.ts @@ -0,0 +1,8 @@ +/** + * Loading, empty and error states for app v3. Each async view uses + * these, so a wait, an empty list and a failure read the same way on + * every surface. + */ +export { default as EmptyState } from './EmptyState'; +export { default as ErrorState } from './ErrorState'; +export { CARD_GRID_STYLE, CardGridSkeleton, SkeletonLine, SkeletonRegion } from './Skeletons'; diff --git a/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx b/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx new file mode 100644 index 000000000..425d8fcb4 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { EmptyState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; + +describe('EmptyState', () => { + test('given a title and an action then both show, the action usable', async () => { + // Given + const user = userEvent.setup(); + const onBuild = vi.fn(); + render( + Build a reform} + /> + ); + + // When + await user.click(screen.getByRole('button', { name: 'Build a reform' })); + + // Then + expect(screen.getByText('No saved reforms yet.')).toBeInTheDocument(); + expect(onBuild).toHaveBeenCalledTimes(1); + }); + + test('given a description then it reads under the title', () => { + render(); + + expect(screen.getByText('Try another place.')).toBeInTheDocument(); + }); + + test('given no action then it is a message alone, not an alert', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx b/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx new file mode 100644 index 000000000..a1f9954c4 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; + +describe('ErrorState', () => { + test('given a failure then it is announced as an alert, with its title as a heading', () => { + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the report'); + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + }); + + test('given no retry then no try again button shows', () => { + render(Back to Build} />); + + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Back to Build' })).toBeInTheDocument(); + }); + + test('given try again is clicked then the retry runs', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a retry still in flight then the button waits for it, then comes back', async () => { + // Given + const user = userEvent.setup(); + let settle: () => void = () => {}; + const onRetry = vi.fn( + () => + new Promise((resolve) => { + settle = resolve; + }) + ); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('button', { name: 'Trying again…' })).toBeDisabled(); + + // And when the retry settles, the button is back + settle(); + expect(await screen.findByRole('button', { name: 'Try again' })).toBeEnabled(); + }); + + test('given a failed retry then the button comes back for another try', async () => { + const user = userEvent.setup(); + const onRetry = vi.fn().mockRejectedValue(new Error('still down')); + render(); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + + expect(await screen.findByRole('button', { name: 'Try again' })).toBeEnabled(); + }); + + test('given the compact form then the message and retry show without a heading', () => { + render( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'The budget window could not be calculated' + ); + expect(screen.getByText('The 2031 simulation failed.')).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx b/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx new file mode 100644 index 000000000..41c4c22b1 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import { CardGridSkeleton, SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; + +describe('SkeletonRegion', () => { + test('given a label then the region is a busy status named for what is loading', () => { + render( + + + + ); + + const region = screen.getByRole('status', { name: 'Loading the bill' }); + expect(region).toHaveAttribute('aria-busy', 'true'); + }); +}); + +describe('CardGridSkeleton', () => { + test('given a count then that many placeholder cards fill the grid', () => { + render(); + + const grid = screen.getByRole('status', { name: 'Loading bills' }); + expect(grid.children).toHaveLength(4); + }); + + test('given no count then a page of six cards stands in', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading your reforms' }).children).toHaveLength(6); + }); +}); From 4e823cf80e0514790119ae06d9d5523b6e4ccf99 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:20 -0400 Subject: [PATCH 10/27] Build: parameter placeholders, and a retry when metadata fails While the parameter index loads, placeholders in the shape of the search bar (or of the draft's table) hold its place. If the model's metadata fails, Build says so and asks for it again instead of loading for good. Co-Authored-By: Claude Opus 5.5 --- .../flagship/reform/AddParameterBar.tsx | 32 +---- .../components/flagship/reform/DraftTable.tsx | 4 +- .../flagship/reform/ParametersStatus.tsx | 130 ++++++++++++++++++ app/src/hooks/useFlagshipMetadataStatus.ts | 35 +++++ app/src/pages/flagship/Build.page.tsx | 5 +- .../hooks/useFlagshipMetadataStatusMocks.ts | 30 ++++ .../hooks/useFlagshipMetadataStatus.test.tsx | 77 +++++++++++ .../tests/unit/pages/flagship/Build.test.tsx | 63 ++++++++- 8 files changed, 339 insertions(+), 37 deletions(-) create mode 100644 app/src/components/flagship/reform/ParametersStatus.tsx create mode 100644 app/src/hooks/useFlagshipMetadataStatus.ts create mode 100644 app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts create mode 100644 app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx diff --git a/app/src/components/flagship/reform/AddParameterBar.tsx b/app/src/components/flagship/reform/AddParameterBar.tsx index c49761067..2cfaa50eb 100644 --- a/app/src/components/flagship/reform/AddParameterBar.tsx +++ b/app/src/components/flagship/reform/AddParameterBar.tsx @@ -3,7 +3,7 @@ import { IconListTree, IconX } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; import PolicyBrowser from '@/components/flagship/policyBrowser/PolicyBrowser'; -import { Spinner } from '@/components/ui'; +import ParametersStatus from '@/components/flagship/reform/ParametersStatus'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDraftReform } from '@/libs/draftReform'; @@ -47,33 +47,6 @@ interface AddParameterBarProps { * out of the way the moment a parameter is picked — the reform below * stays the page. */ -/** - * The parameter index takes a moment on the US tree. The page shows - * this once, in the add bar's own footprint, so nothing jumps when the - * bar and the reform below it arrive. - */ -export function ParametersLoading() { - return ( -
- - Loading parameters… -
- ); -} - export default function AddParameterBar({ onPick, autoFocus = false, @@ -153,8 +126,9 @@ export default function AddParameterBar({ return () => observer.disconnect(); }, [inTable, entries.length]); + // Until the index is in, the bar's place holds its loading (or failed) state. if (entries.length === 0) { - return ; + return ; } return ( diff --git a/app/src/components/flagship/reform/DraftTable.tsx b/app/src/components/flagship/reform/DraftTable.tsx index 0357043f2..9f67e586e 100644 --- a/app/src/components/flagship/reform/DraftTable.tsx +++ b/app/src/components/flagship/reform/DraftTable.tsx @@ -32,9 +32,9 @@ interface DraftRow { // Narrow tables drop the current-law and when columns; the row's // details still have both. -const ROW_GRID = +export const ROW_GRID = 'tw:grid tw:items-center tw:gap-3 tw:grid-cols-[minmax(0,1fr)_150px_64px] tw:@min-[640px]:grid-cols-[minmax(0,1fr)_110px_150px_136px_64px]'; -const WIDE_ONLY = 'tw:hidden tw:@min-[640px]:block'; +export const WIDE_ONLY = 'tw:hidden tw:@min-[640px]:block'; /** One line, cut with an ellipsis: list values run to dozens of names. */ const truncate: React.CSSProperties = { diff --git a/app/src/components/flagship/reform/ParametersStatus.tsx b/app/src/components/flagship/reform/ParametersStatus.tsx new file mode 100644 index 000000000..9d3e90d5d --- /dev/null +++ b/app/src/components/flagship/reform/ParametersStatus.tsx @@ -0,0 +1,130 @@ +import { IconSearch } from '@tabler/icons-react'; +import { ErrorState, SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; +import { colors, spacing, typography } from '@/designTokens'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { ROW_GRID, WIDE_ONLY } from './DraftTable'; + +/** The search bar and its filter row, as placeholders at their own size. */ +export function SearchBarSkeleton() { + return ( +
+
+ + {/* The input's line box, so the bar is as tall as the real one. */} +
+ +
+
+
+ + + +
+
+ ); +} + +const headerCell: React.CSSProperties = { + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.text.secondary, +}; + +/** The reform table with placeholder rows, the add bar's placeholder at its foot. */ +function DraftTableSkeleton({ rows }: { rows: number }) { + const count = Math.max(1, Math.min(rows, 8)); + return ( +
+
+ Parameter + + Current law + + New value + + Effective + + +
+ {Array.from({ length: count }, (_, index) => ( +
0 ? `1px solid ${colors.border.light}` : undefined, + }} + > + + + + + + + + + +
+ ))} +
+ +
+
+ ); +} + +/** + * Build waits on the model's parameters: the add bar has nothing to + * search and the table cannot group breakdowns until they arrive. While + * they load, placeholders hold the bar's place (and the table's, for a + * draft of `rows` parameters); if they fail, the error says so and asks + * for them again. + */ +export default function ParametersStatus({ rows = 0 }: { rows?: number }) { + const metadata = useFlagshipMetadataStatus(); + + if (metadata.status === 'error') { + return ( +
+ +
+ ); + } + + return ( + + {rows > 0 ? : } + + ); +} diff --git a/app/src/hooks/useFlagshipMetadataStatus.ts b/app/src/hooks/useFlagshipMetadataStatus.ts new file mode 100644 index 000000000..4c18ee752 --- /dev/null +++ b/app/src/hooks/useFlagshipMetadataStatus.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import type { AppDispatch, RootState } from '@/store'; + +export type FlagshipMetadataStatus = 'loading' | 'error' | 'ready'; + +/** + * Where the model's metadata stands for the current country, for app v3 + * views that wait on it: still loading, failed, or ready. A failed fetch + * is not tried again on its own (the loader runs once per country), so + * `retry` asks for it again. + */ +export function useFlagshipMetadataStatus(): { + status: FlagshipMetadataStatus; + /** The fetch's error message, when it failed. */ + message: string | null; + retry: () => unknown; +} { + const countryId = useCurrentCountry(); + const dispatch = useDispatch(); + const loading = useSelector((state: RootState) => state.metadata.loading); + const error = useSelector((state: RootState) => state.metadata.error); + const ready = useSelector( + (state: RootState) => + state.metadata.version !== null && state.metadata.currentCountry === countryId + ); + + const retry = useCallback(() => dispatch(fetchMetadataThunk(countryId)), [dispatch, countryId]); + + const status: FlagshipMetadataStatus = + !loading && error ? 'error' : ready && !loading ? 'ready' : 'loading'; + return { status, message: status === 'error' ? error : null, retry }; +} diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index 5336f421d..ef811cda9 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -1,7 +1,8 @@ import { useSelector } from 'react-redux'; -import AddParameterBar, { ParametersLoading } from '@/components/flagship/reform/AddParameterBar'; +import AddParameterBar from '@/components/flagship/reform/AddParameterBar'; import DraftHeader, { DraftRunBar } from '@/components/flagship/reform/DraftHeader'; import DraftTable from '@/components/flagship/reform/DraftTable'; +import ParametersStatus from '@/components/flagship/reform/ParametersStatus'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; import { Title } from '@/components/ui'; import { spacing } from '@/designTokens'; @@ -60,7 +61,7 @@ export default function BuildPage() { <> {loading ? ( - + ) : ( // The next parameter is added at the foot of the reform, where // the last one ends: a reform takes as many as it needs. diff --git a/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts new file mode 100644 index 000000000..100ecab97 --- /dev/null +++ b/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts @@ -0,0 +1,30 @@ +import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { store } from '@/store'; + +export const METADATA_ERROR_MESSAGE = 'Failed to fetch metadata for us: 503 Service Unavailable'; + +const REQUEST_ID = 'flagship-states-test'; + +/** Puts the store where a metadata fetch that is still in flight leaves it. */ +export function seedMetadataLoading(countryId = 'us') { + store.dispatch({ + type: fetchMetadataThunk.pending.type, + meta: { arg: countryId, requestId: REQUEST_ID, requestStatus: 'pending' }, + }); +} + +/** Puts the store where a failed metadata fetch leaves it. */ +export function seedMetadataError(message = METADATA_ERROR_MESSAGE, countryId = 'us') { + seedMetadataLoading(countryId); + store.dispatch({ + type: fetchMetadataThunk.rejected.type, + meta: { + arg: countryId, + requestId: REQUEST_ID, + requestStatus: 'rejected', + rejectedWithValue: true, + }, + payload: message, + error: { message: 'Rejected' }, + }); +} diff --git a/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx b/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx new file mode 100644 index 000000000..652148ee5 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + METADATA_ERROR_MESSAGE, + seedMetadataError, + seedMetadataLoading, +} from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useFlagshipMetadataStatus', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + mockFetchMetadata.mockReset(); + // A retried fetch stays in flight, so the status it leaves can be read. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); + }); + + test('given a fetch in flight then the status is loading', () => { + seedMetadataLoading(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('loading'); + expect(result.current.message).toBeNull(); + }); + + test('given a failed fetch then the status is error, with its message', () => { + seedMetadataError(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('error'); + expect(result.current.message).toBe(METADATA_ERROR_MESSAGE); + }); + + test('given the metadata arrived then the status is ready', () => { + seedDeductionMetadata(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('ready'); + }); + + test('given a failed fetch then retry asks for the country again', () => { + // Given + seedMetadataError(); + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // When + act(() => { + result.current.retry(); + }); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(result.current.status).toBe('loading'); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx index 8e2929da5..9fe556945 100644 --- a/app/src/tests/unit/pages/flagship/Build.test.tsx +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -1,11 +1,19 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { addDraftProvision, clearDraftReform, startDraftReform } from '@/libs/draftReform'; import BuildPage from '@/pages/flagship/Build.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); const CTC = { path: 'gov.irs.credits.ctc.amount.base', @@ -27,6 +35,9 @@ function renderPage() { describe('BuildPage', () => { beforeEach(() => { clearDraftReform(); + mockFetchMetadata.mockReset(); + // A retried fetch stays in flight: the page shows its loading state. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); }); afterEach(() => { @@ -39,7 +50,7 @@ describe('BuildPage', () => { // Then expect(screen.getByRole('heading', { name: 'Build a reform' })).toBeInTheDocument(); - expect(screen.getAllByText('Loading parameters…')).toHaveLength(1); + expect(screen.getAllByRole('status', { name: 'Loading parameters' })).toHaveLength(1); expect(screen.queryByRole('button', { name: 'Rename the reform' })).not.toBeInTheDocument(); }); @@ -72,7 +83,51 @@ describe('BuildPage', () => { // Then expect(screen.getByRole('button', { name: 'Rename the reform' })).toBeInTheDocument(); - expect(screen.getAllByText('Loading parameters…')).toHaveLength(1); + const loading = screen.getAllByRole('status', { name: 'Loading parameters' }); + expect(loading).toHaveLength(1); + // The placeholder is the table's shape, so the table takes its place without a jump. + expect(loading[0]).toHaveTextContent('New value'); expect(screen.queryByRole('button', { name: /open base amount/i })).not.toBeInTheDocument(); }); + + test('given metadata failed then the page says so instead of loading for good', () => { + // Given + seedMetadataError(); + + // When + renderPage(); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + expect(screen.queryByRole('status', { name: 'Loading parameters' })).not.toBeInTheDocument(); + }); + + test('given metadata failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + seedMetadataError(); + renderPage(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(await screen.findByRole('status', { name: 'Loading parameters' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given a draft and metadata failed then the error stands in for the table', () => { + // Given + startDraftReform('us', 'manual'); + addDraftProvision('us', CTC); + seedMetadataError(); + + // When + renderPage(); + + // Then + expect(screen.getByRole('button', { name: 'Rename the reform' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + }); }); From f6f1f6df4d94be860c81517df285a514445806e2 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:24 -0400 Subject: [PATCH 11/27] Build: a search with no match offers to clear it Co-Authored-By: Claude Opus 5.5 --- .../flagship/ParameterSearchBox.tsx | 55 ++++++++++--------- .../flagship/ParameterSearchBox.test.tsx | 16 ++++++ 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index e402cda36..89e6abf1b 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { IconArrowLeft, IconCheck, @@ -7,7 +7,8 @@ import { IconInfoCircle, IconSearch, } from '@tabler/icons-react'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { EmptyState } from '@/components/flagship/states'; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useParameterSearch } from '@/hooks/useParameterSearch'; import { @@ -312,6 +313,7 @@ export default function ParameterSearchBox({ autoFocus = false, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); + const inputRef = useRef(null); const [highlighted, setHighlighted] = useState(0); const [hoveredFolder, setHoveredFolder] = useState(null); /** @@ -541,6 +543,7 @@ export default function ParameterSearchBox({ > { setQuery(event.target.value); @@ -662,30 +665,30 @@ export default function ParameterSearchBox({
{noMatches && ( -
- - No parameters match{' '} - “{query.trim()}” - {scopePhrase}. - - - Nothing matches anywhere else either — check the spelling, or try fewer words. - +
+ + No parameters match{' '} + “{query.trim()}” + {scopePhrase}. + + } + action={ + + } + />
)} diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index 320b8492e..c009e42e2 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -390,6 +390,22 @@ describe('ParameterSearchBox', () => { expect(screen.getByText(/no parameters match/i)).toHaveTextContent('zzzz quantum flux'); }); + test('given a query with no match then clear search empties the box and returns to it', async () => { + // Given + const user = userEvent.setup(); + render(); + const search = screen.getByRole('combobox', { name: /search parameters/i }); + await user.type(search, 'zzzz quantum flux'); + + // When + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + // Then + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + expect(screen.queryByText(/no parameters match/i)).not.toBeInTheDocument(); + }); + test('given a scope with no match then other jurisdictions wait until asked for', async () => { // Given const user = userEvent.setup(); From 56a9cf6eb2338a65e19eccdfbd3a609e32ab668c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:28 -0400 Subject: [PATCH 12/27] Reforms: card placeholders, retries and empty states Bills and saved reforms load as placeholder cards in the grid's own tracks. A failed feed or store says so with Try again. No saved reforms offers Build; a search that hides every card offers to clear it, and "No bills match." with "Clear filters" uses the bill feed's wording. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useTrackedBills.ts | 5 +- app/src/pages/flagship/Reforms.page.tsx | 105 ++++++---- .../pages/flagship/ReformsStates.test.tsx | 194 ++++++++++++++++++ 3 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 app/src/tests/unit/pages/flagship/ReformsStates.test.tsx diff --git a/app/src/hooks/useTrackedBills.ts b/app/src/hooks/useTrackedBills.ts index f258624e5..389ca892b 100644 --- a/app/src/hooks/useTrackedBills.ts +++ b/app/src/hooks/useTrackedBills.ts @@ -17,8 +17,10 @@ export function useTrackedBills(countryId: CountryId): { isConfigured: boolean; isLoading: boolean; isError: boolean; + /** Asks the feed again after a failure; settles when the answer is in. */ + retry: () => Promise; } { - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['tracker-bills'], queryFn: fetchTrackerBills, staleTime: 5 * 60 * 1000, @@ -41,5 +43,6 @@ export function useTrackedBills(countryId: CountryId): { isConfigured: data !== null, isLoading, isError, + retry: refetch, }; } diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4542b6c02..23947c698 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -19,10 +19,16 @@ import { getReformStore } from '@/api/reformStore'; import ProvisionList from '@/components/flagship/ProvisionList'; import ReformReports from '@/components/flagship/ReformReports'; import ScopePicker from '@/components/flagship/ScopePicker'; +import { + CARD_GRID_STYLE, + CardGridSkeleton, + EmptyState, + ErrorState, +} from '@/components/flagship/states'; import StatTile from '@/components/flagship/StatTile'; import ValueInput from '@/components/flagship/ValueInput'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; +import { Button, Stack, Text, Title } from '@/components/ui'; import { FOREVER } from '@/constants'; import { useAppLocation } from '@/contexts/LocationContext'; import { useAppNavigate } from '@/contexts/NavigationContext'; @@ -276,6 +282,7 @@ export default function ReformsPage() { isConfigured: billsConfigured, isLoading: billsLoading, isError: billsError, + retry: retryBills, } = useTrackedBills(countryId); const { @@ -850,6 +857,11 @@ export default function ReformsPage() { } // ---- The browsing surface ---- + const buildAction = ( + + ); return ( @@ -936,62 +948,73 @@ export default function ReformsPage() {
- {tab === 'yours' && isPending && } + {tab === 'yours' && isPending && } {tab === 'yours' && isError && ( - - Could not load your reforms. Try reloading the page. - + )} - {tab === 'bills' && billsLoading && } + {tab === 'bills' && billsLoading && } {tab === 'bills' && billsError && ( - - Could not load the bill feed. Try reloading the page. - + )} {tab === 'bills' && !billsLoading && !billsError && !billsConfigured && ( - - The bill feed is not configured for this deployment. - + } + title="The bill feed is not configured here." + action={buildAction} + /> )} {tab === 'bills' && !billsLoading && !billsError && billsConfigured && ( <> {bills.length === 0 && ( - - No analyzed bills yet for this country. - + } + title="No analyzed bills yet." + action={buildAction} + /> )} {bills.length > 0 && visibleBills.length === 0 && ( - - No bills match — adjust the search or place filter. - + { + setQuery(''); + setPlaceFilter('all'); + }} + > + Clear filters + + } + /> )} )} - {tab === 'yours' && !isPending && visibleReforms.length === 0 && ( - - - - No saved reforms yet — build one, or ask a policy question. - - - )} + {/* Saved reforms a search hides are a different empty from none at all. */} + {tab === 'yours' && + !isPending && + !isError && + visibleReforms.length === 0 && + ((reforms ?? []).length === 0 ? ( + } + title="No saved reforms yet." + action={buildAction} + /> + ) : ( + setQuery('')}> + Clear search + + } + /> + ))} -
+
{tab === 'bills' && pagedBills.map((bill) => ( ({ + ...(await importOriginal()), + getReformStore: () => ({ + findMine: mockFindMine, + delete: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }), +})); + +vi.mock('@/api/flagshipReportStore', async (importOriginal) => ({ + ...(await importOriginal()), + getFlagshipReportStore: () => ({ findMine: async () => [] }), +})); + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: vi.fn(), isRunning: false, error: null }), +})); + +const mockFetchTrackerBills = vi.fn(); + +vi.mock('@/api/billFeed', async (importOriginal) => ({ + ...(await importOriginal()), + fetchTrackerBills: () => mockFetchTrackerBills(), +})); + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => mockNavigate, + useParams: () => ({ countryId: 'us' }), +})); + +const SAVED_REFORM: Reform = { + id: 'rf-1', + countryId: 'us', + label: 'CTC to $3,600 for children under 6', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base[0].amount', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1' }, + updatedAt: '2026-08-01T00:00:00Z', +}; + +/** A search that matches no bill and no reform. */ +const NO_MATCH_QUERY = 'zzzz quantum flux'; + +function renderReforms() { + // No retry delay, so the feed's own retry settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + return render( + + + + ); +} + +async function openYours(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Yours' })); +} + +describe('ReformsPage states', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearDraftReform(); + mockFindMine.mockResolvedValue([]); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + }); + + test('given the bill feed is loading then card placeholders hold the grid', () => { + mockFetchTrackerBills.mockReturnValue(new Promise(() => {})); + + renderReforms(); + + expect(screen.getByRole('status', { name: 'Loading bills' })).toBeInTheDocument(); + }); + + test('given saved reforms are loading then card placeholders hold the grid', async () => { + const user = userEvent.setup(); + mockFindMine.mockReturnValue(new Promise(() => {})); + renderReforms(); + + await openYours(user); + + expect(screen.getByRole('status', { name: 'Loading your reforms' })).toBeInTheDocument(); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + }); + + test('given the bill feed failed then try again brings the bills', async () => { + // Given + const user = userEvent.setup(); + mockFetchTrackerBills.mockRejectedValue(new Error('tracker down')); + renderReforms(); + await screen.findByRole('alert'); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(TRACKED_BILL.title)).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given saved reforms failed to load then it says so instead of an empty list', async () => { + const user = userEvent.setup(); + mockFindMine.mockRejectedValue(new Error('store down')); + renderReforms(); + + await openYours(user); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load your reforms'); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + }); + + test('given saved reforms failed then try again lists them', async () => { + // Given + const user = userEvent.setup(); + mockFindMine.mockRejectedValue(new Error('store down')); + renderReforms(); + await openYours(user); + await screen.findByRole('alert'); + mockFindMine.mockResolvedValue([SAVED_REFORM]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(SAVED_REFORM.label!)).toBeInTheDocument(); + }); + + test('given no saved reforms then build a reform opens Build', async () => { + const user = userEvent.setup(); + renderReforms(); + await openYours(user); + + await user.click(await screen.findByRole('button', { name: 'Build a reform' })); + + expect(screen.getByText('No saved reforms yet.')).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith('/us/build'); + }); + + test('given a search that hides every saved reform then clear search brings them back', async () => { + // Given + const user = userEvent.setup(); + mockFindMine.mockResolvedValue([SAVED_REFORM]); + renderReforms(); + await openYours(user); + await screen.findByText(SAVED_REFORM.label!); + await user.type(screen.getByRole('textbox', { name: 'Search reforms' }), NO_MATCH_QUERY); + expect(screen.getByText('No reforms match.')).toBeInTheDocument(); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + // Then + expect(screen.getByText(SAVED_REFORM.label!)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Search reforms' })).toHaveValue(''); + }); + + test('given a search that hides every bill then clear filters brings them back', async () => { + // Given + const user = userEvent.setup(); + renderReforms(); + await screen.findByText(TRACKED_BILL.title); + await user.type(screen.getByRole('textbox', { name: 'Search reforms' }), NO_MATCH_QUERY); + expect(screen.getByText('No bills match.')).toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + + // Then + expect(screen.getByText(TRACKED_BILL.title)).toBeInTheDocument(); + expect(screen.queryByText('No bills match.')).not.toBeInTheDocument(); + }); +}); From 1177095a3f9926f142c25d63a0ba8782e9153bcc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:34 -0400 Subject: [PATCH 13/27] Report: not found and failed loads end in a state, not a wait A link that names no report says "Report not found" at once, without retries. A report, simulation or policy that fails to load shows "Could not load the report" with Try again, which asks only for what failed. Until the run starts, a placeholder holds the run card's place. The calculation error now uses the shared ErrorState. Co-Authored-By: Claude Opus 5.5 --- .../report/ReportCalculationError.tsx | 69 ++-------- .../flagship/report/ReportLoadError.tsx | 44 ++++++ .../flagship/report/RunProgress.tsx | 44 ++++-- app/src/hooks/useFlagshipReport.ts | 34 ++++- app/src/libs/flagship/loadError.ts | 16 +++ app/src/pages/flagship/Report.page.tsx | 31 ++++- .../fixtures/hooks/useFlagshipReportMocks.ts | 41 ++++++ .../flagship/report/ReportLoadError.test.tsx | 55 ++++++++ .../unit/hooks/useFlagshipReport.test.tsx | 129 ++++++++++++++++++ .../unit/libs/flagship/loadError.test.ts | 46 +++++++ .../pages/flagship/ReportLoadStates.test.tsx | 110 +++++++++++++++ 11 files changed, 539 insertions(+), 80 deletions(-) create mode 100644 app/src/components/flagship/report/ReportLoadError.tsx create mode 100644 app/src/libs/flagship/loadError.ts create mode 100644 app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts create mode 100644 app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx create mode 100644 app/src/tests/unit/hooks/useFlagshipReport.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/loadError.test.ts create mode 100644 app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx diff --git a/app/src/components/flagship/report/ReportCalculationError.tsx b/app/src/components/flagship/report/ReportCalculationError.tsx index df5ae9528..8e81eb814 100644 --- a/app/src/components/flagship/report/ReportCalculationError.tsx +++ b/app/src/components/flagship/report/ReportCalculationError.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { IconAlertTriangle, IconCheck, IconCopy } from '@tabler/icons-react'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { ErrorState } from '@/components/flagship/states'; import { Button } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; @@ -37,61 +38,11 @@ export default function ReportCalculationError({ }; return ( -
-
- - - -
-

- {summary.title} -

-

- {summary.body} -

-
-
- -
- {summary.retryable && } + window.location.reload() : undefined} + actions={ -
- + } + > {summary.reference && (
-
+ ); } diff --git a/app/src/components/flagship/report/ReportLoadError.tsx b/app/src/components/flagship/report/ReportLoadError.tsx new file mode 100644 index 000000000..fc4b74d2a --- /dev/null +++ b/app/src/components/flagship/report/ReportLoadError.tsx @@ -0,0 +1,44 @@ +import { ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +/** + * A report that could not be opened: the link names no report, or the + * report (or what it ran on) failed to load. A missing report has + * nothing to retry, so it points to where reports are found; a failed + * load asks again. + */ +export default function ReportLoadError({ + notFound, + onRetry, +}: { + notFound: boolean; + onRetry: () => unknown; +}) { + const nav = useAppNavigate(); + const countryId = useCurrentCountry(); + const backToBuild = ( + + ); + + if (notFound) { + return ( + + + {backToBuild} + + } + /> + ); + } + return ; +} diff --git a/app/src/components/flagship/report/RunProgress.tsx b/app/src/components/flagship/report/RunProgress.tsx index 757788613..9b91a3d33 100644 --- a/app/src/components/flagship/report/RunProgress.tsx +++ b/app/src/components/flagship/report/RunProgress.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; import { Progress, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { formatElapsed } from '@/libs/flagship/reportStages'; @@ -14,6 +15,35 @@ function useElapsed(): number { return now - started; } +/** The run's card, shared with its placeholder so one replaces the other in place. */ +const CARD_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: 640, + gap: spacing.sm, + padding: spacing.lg, + border: `1px solid ${colors.border.light}`, + borderRadius: 12, + background: colors.background.primary, +}; + +/** + * The run's card before there is a run to show — the report and its + * status still loading — at the size the run's bar takes once it starts. + */ +export function RunProgressSkeleton({ label }: { label: string }) { + return ( + +
+ + +
+ +
+ ); +} + /** * A society-wide run in progress, as a bar: what is running, how long it * has taken, and how far along a typical run of this size would be. The @@ -45,19 +75,7 @@ export default function RunProgress({ const percent = Math.max(0, Math.min(Math.round(value), 99)); return ( - + Promise; } /** @@ -43,11 +50,18 @@ export function useFlagshipReport(id: string): FlagshipReportData { const legacy = useUserReportById(id, { enabled: !direct && !!id }); - const { data: report, isLoading: reportLoading } = useQuery({ + const { + data: report, + isLoading: reportLoading, + error: reportError, + refetch: refetchReport, + } = useQuery({ queryKey: reportKeys.byId(id), queryFn: async () => ReportAdapter.fromMetadata(await fetchReportById(country, id)), enabled: direct, staleTime: 5 * 60 * 1000, + // A missing report stays missing: say so at once rather than retrying. + retry: retryUnlessNotFound, }); const simulationIds = direct ? (report?.simulationIds ?? []) : []; @@ -80,12 +94,30 @@ export function useFlagshipReport(id: string): FlagshipReportData { simulations: legacy.simulations, policies: legacy.policies ?? [], isLoading: legacy.isLoading, + // A legacy link that resolves to nothing reads as unresolvable. + error: null, + notFound: false, + retry: async () => undefined, }; } + const error = + (reportError as Error | null) ?? + (simulationResults.error as Error | null) ?? + (policyResults.error as Error | null) ?? + null; return { report, simulations, policies, isLoading: reportLoading || simulationResults.isLoading || policyResults.isLoading, + error, + notFound: !!reportError && isNotFoundError(reportError), + retry: () => + Promise.all([ + reportError ? refetchReport() : null, + ...[...simulationResults.queries, ...policyResults.queries] + .filter((query) => query.error) + .map((query) => query.refetch()), + ]), }; } diff --git a/app/src/libs/flagship/loadError.ts b/app/src/libs/flagship/loadError.ts new file mode 100644 index 000000000..e071efb1c --- /dev/null +++ b/app/src/libs/flagship/loadError.ts @@ -0,0 +1,16 @@ +/** + * Whether a failed fetch means the record is not there — a 404 status + * ("Failed to fetch report 12: 404 Not Found"), or the API saying it was + * not found — rather than that the request failed. A missing record is + * not worth asking for again; a failed request is. + */ +export function isNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + // The status follows a colon, so a record id that reads "404" is not one. + return /:\s*404\b|\bnot found\b/i.test(message); +} + +/** Retries for a record fetch: a few for a failed request, none for a missing record. */ +export function retryUnlessNotFound(failureCount: number, error: unknown): boolean { + return !isNotFoundError(error) && failureCount < 3; +} diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 93fe14623..5441dd46d 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -5,11 +5,12 @@ import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/society import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; +import ReportLoadError from '@/components/flagship/report/ReportLoadError'; import ReportView from '@/components/flagship/report/ReportView'; -import RunProgress from '@/components/flagship/report/RunProgress'; +import RunProgress, { RunProgressSkeleton } from '@/components/flagship/report/RunProgress'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; import { trackRecordPrograms, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; -import { Spinner, Stack, Text, Title } from '@/components/ui'; +import { Stack, Title } from '@/components/ui'; import { getDurationForCountry } from '@/constants/calculationDurations'; import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; @@ -38,7 +39,15 @@ interface FlagshipReportPageProps { export default function FlagshipReportPage({ userReportId: propId }: FlagshipReportPageProps) { const params = useParams<{ userReportId: string }>(); const userReportId = propId ?? params.userReportId ?? ''; - const { report, simulations, policies, isLoading } = useFlagshipReport(userReportId); + const { + report, + simulations, + policies, + isLoading, + error: loadError, + notFound, + retry, + } = useFlagshipReport(userReportId); // A local association id from another browser resolves to nothing. const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); const parameters = useSelector((state: RootState) => state.metadata.parameters); @@ -148,6 +157,17 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } + // A report that failed to load would otherwise wait here for good. + if (loadError) { + return layout( + + + {title} + + + + ); + } if (calcStatus.isError) { return layout( @@ -200,10 +220,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep expectedMs={getDurationForCountry(report?.countryId ?? 'us')} /> ) : ( - <> - - Loading report… - + )} ); diff --git a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts new file mode 100644 index 000000000..12190c773 --- /dev/null +++ b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts @@ -0,0 +1,41 @@ +import type { ReportMetadata } from '@/types/metadata/reportMetadata'; +import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; + +/** A report link that resolves: the API report id of a nationwide run. */ +export const EXISTING_REPORT_ID = '9001'; +/** A report link that names no report. */ +export const MISSING_REPORT_ID = '404404'; + +export const BASELINE_SIMULATION_ID = '501'; +export const REFORM_SIMULATION_ID = '502'; + +/** As fetchReportById throws on a 404. */ +export const REPORT_NOT_FOUND_ERROR = new Error( + `Failed to fetch report ${MISSING_REPORT_ID}: 404 Not Found` +); +/** As fetchSimulationById throws when the API is down. */ +export const SIMULATION_UNAVAILABLE_ERROR = new Error( + `Failed to fetch simulation ${REFORM_SIMULATION_ID}: 503 Service Unavailable` +); + +export const EXISTING_REPORT_METADATA: ReportMetadata = { + id: Number(EXISTING_REPORT_ID), + country_id: 'us', + year: '2026', + api_version: 'v1', + simulation_1_id: BASELINE_SIMULATION_ID, + simulation_2_id: REFORM_SIMULATION_ID, + status: 'pending', + output: null, +}; + +export const nationwideSimulationMetadata = (id: string, policyId: string): SimulationMetadata => ({ + id: Number(id), + country_id: 'us', + api_version: 'v1', + population_id: 'us', + population_type: 'geography', + policy_id: policyId, + status: 'pending', + output: null, +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx new file mode 100644 index 000000000..1b8e61c1b --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportLoadError from '@/components/flagship/report/ReportLoadError'; + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +describe('ReportLoadError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given a link that names no report then it says not found, with no retry', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + test('given a report not found then your reforms is one click away', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Your reforms' })); + + expect(mockPush).toHaveBeenCalledWith('/us/reforms?filter=yours'); + }); + + test('given a failed load then try again asks for the report again', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a failed load then back to Build leaves for Build', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Back to Build' })); + + expect(mockPush).toHaveBeenCalledWith('/us/build'); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx new file mode 100644 index 000000000..a5ae3c743 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { store } from '@/store'; +import { + BASELINE_SIMULATION_ID, + EXISTING_REPORT_ID, + EXISTING_REPORT_METADATA, + MISSING_REPORT_ID, + nationwideSimulationMetadata, + REFORM_SIMULATION_ID, + REPORT_NOT_FOUND_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; + +const mockFetchReportById = vi.fn(); +const mockFetchSimulationById = vi.fn(); + +vi.mock('@/api/report', async (importOriginal) => ({ + ...(await importOriginal()), + fetchReportById: (...args: unknown[]) => mockFetchReportById(...args), +})); + +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSimulationById: (...args: unknown[]) => mockFetchSimulationById(...args), +})); + +// Old association links are not under test here. +vi.mock('@/hooks/useUserReports', () => ({ + useUserReportById: () => ({ report: undefined, simulations: [], policies: [], isLoading: false }), +})); + +function renderReportHook(id: string) { + // No retry delay: a failed request that retries settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return renderHook(() => useFlagshipReport(id), { wrapper }); +} + +describe('useFlagshipReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchReportById.mockResolvedValue(EXISTING_REPORT_METADATA); + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => + nationwideSimulationMetadata(id, id === BASELINE_SIMULATION_ID ? '2' : '98557') + ); + }); + + test('given a link to a report then it loads with no error', async () => { + const { result } = renderReportHook(EXISTING_REPORT_ID); + + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + expect(result.current.error).toBeNull(); + expect(result.current.notFound).toBe(false); + }); + + test('given a link that names no report then it is not found, asked for once', async () => { + // Given + mockFetchReportById.mockRejectedValue(REPORT_NOT_FOUND_ERROR); + + // When + const { result } = renderReportHook(MISSING_REPORT_ID); + + // Then + await waitFor(() => expect(result.current.notFound).toBe(true)); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(REPORT_NOT_FOUND_ERROR); + expect(mockFetchReportById).toHaveBeenCalledTimes(1); + }); + + test('given a simulation fails to load then the error says so, and the report is found', async () => { + // Given + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { + if (id === REFORM_SIMULATION_ID) { + throw SIMULATION_UNAVAILABLE_ERROR; + } + return nationwideSimulationMetadata(id, '2'); + }); + + // When + const { result } = renderReportHook(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(result.current.error).toBe(SIMULATION_UNAVAILABLE_ERROR)); + expect(result.current.notFound).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + + test('given a failed simulation then retry asks for it again and the error clears', async () => { + // Given + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { + if (id === REFORM_SIMULATION_ID) { + throw SIMULATION_UNAVAILABLE_ERROR; + } + return nationwideSimulationMetadata(id, '2'); + }); + const { result } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.error).not.toBeNull()); + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => + nationwideSimulationMetadata(id, id === BASELINE_SIMULATION_ID ? '2' : '98557') + ); + + // When + await act(async () => { + await result.current.retry(); + }); + + // Then + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.simulations).toHaveLength(2); + // Only the failed simulation is asked for again. + expect( + mockFetchSimulationById.mock.calls.filter(([, id]) => id === BASELINE_SIMULATION_ID) + ).toHaveLength(1); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/loadError.test.ts b/app/src/tests/unit/libs/flagship/loadError.test.ts new file mode 100644 index 000000000..acb06885d --- /dev/null +++ b/app/src/tests/unit/libs/flagship/loadError.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; +import { isNotFoundError, retryUnlessNotFound } from '@/libs/flagship/loadError'; +import { + REPORT_NOT_FOUND_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; + +describe('isNotFoundError', () => { + test('given a 404 status then the record is not there', () => { + expect(isNotFoundError(REPORT_NOT_FOUND_ERROR)).toBe(true); + }); + + test('given a 404 without a status text then the record is not there', () => { + expect(isNotFoundError(new Error('Failed to fetch report 12: 404 '))).toBe(true); + }); + + test('given the API says not found then the record is not there', () => { + expect(isNotFoundError(new Error('Report #12 not found'))).toBe(true); + }); + + test('given a server error then the request failed, not the record', () => { + expect(isNotFoundError(SIMULATION_UNAVAILABLE_ERROR)).toBe(false); + }); + + test('given a record id that reads 404 then a server error is still a failure', () => { + expect( + isNotFoundError(new Error('Failed to fetch report 404: 500 Internal Server Error')) + ).toBe(false); + }); + + test('given something that is not an error then it is not a missing record', () => { + expect(isNotFoundError(undefined)).toBe(false); + }); +}); + +describe('retryUnlessNotFound', () => { + test('given a missing record then it is not asked for again', () => { + expect(retryUnlessNotFound(0, REPORT_NOT_FOUND_ERROR)).toBe(false); + }); + + test('given a failed request then it is asked for again a few times', () => { + expect(retryUnlessNotFound(0, SIMULATION_UNAVAILABLE_ERROR)).toBe(true); + expect(retryUnlessNotFound(2, SIMULATION_UNAVAILABLE_ERROR)).toBe(true); + expect(retryUnlessNotFound(3, SIMULATION_UNAVAILABLE_ERROR)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx new file mode 100644 index 000000000..3304d2200 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -0,0 +1,110 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { + EXISTING_REPORT_ID, + MISSING_REPORT_ID, + REPORT_NOT_FOUND_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; + +const mockUseFlagshipReport = vi.fn(); +const mockRetry = vi.fn(); + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => mockUseFlagshipReport(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => ({ + status: 'idle', + isComplete: false, + isError: false, + result: null, + }), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +/** What the report hook returns before anything has loaded. */ +function reportData(overrides: Partial = {}): FlagshipReportData { + return { + report: undefined, + simulations: [], + policies: [], + isLoading: false, + error: null, + notFound: false, + retry: mockRetry, + ...overrides, + }; +} + +function renderPage(userReportId: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage load states', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given the report is loading then the run card placeholder holds its place', () => { + mockUseFlagshipReport.mockReturnValue(reportData({ isLoading: true })); + + renderPage(EXISTING_REPORT_ID); + + expect(screen.getByRole('status', { name: 'Loading report' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given a link that names no report then it says not found, with no retry', () => { + mockUseFlagshipReport.mockReturnValue( + reportData({ error: REPORT_NOT_FOUND_ERROR, notFound: true }) + ); + + renderPage(MISSING_REPORT_ID); + + expect(screen.getByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status', { name: 'Loading report' })).not.toBeInTheDocument(); + }); + + test('given the report failed to load then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + mockUseFlagshipReport.mockReturnValue(reportData({ error: SIMULATION_UNAVAILABLE_ERROR })); + renderPage(EXISTING_REPORT_ID); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + expect(mockRetry).toHaveBeenCalledTimes(1); + }); +}); From 3b464c75d55e71f1cdb97906e0fe6d11367e01fb Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:38 -0400 Subject: [PATCH 14/27] Report: a failed budget window retries in place The window card and the overview's summary both say the window could not be calculated, with Try again. The summary drops its progress link while the window has failed. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/BudgetWindowCard.tsx | 15 ++++++----- .../flagship/report/BudgetWindowSummary.tsx | 22 +++++++++------- .../report/BudgetWindowSummary.test.tsx | 25 ++++++++++++++++--- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/app/src/components/flagship/report/BudgetWindowCard.tsx b/app/src/components/flagship/report/BudgetWindowCard.tsx index a1f05151a..d185ce9b8 100644 --- a/app/src/components/flagship/report/BudgetWindowCard.tsx +++ b/app/src/components/flagship/report/BudgetWindowCard.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import type { BudgetWindowParams, BudgetWindowYear } from '@/api/budgetWindow'; import RunProgress from '@/components/flagship/report/RunProgress'; +import { ErrorState } from '@/components/flagship/states'; import { Button, Select, @@ -263,14 +264,12 @@ export default function BudgetWindowCard({ )} {failed && ( - - - {data?.error || budget.error?.message || 'The budget window could not be calculated.'} - - - + budget.refetch()} + /> )} {result && ( diff --git a/app/src/components/flagship/report/BudgetWindowSummary.tsx b/app/src/components/flagship/report/BudgetWindowSummary.tsx index bd33ffb15..1e329fd51 100644 --- a/app/src/components/flagship/report/BudgetWindowSummary.tsx +++ b/app/src/components/flagship/report/BudgetWindowSummary.tsx @@ -1,4 +1,5 @@ import { IconArrowRight } from '@tabler/icons-react'; +import { ErrorState } from '@/components/flagship/states'; import { Button, Progress, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useBudgetWindow } from '@/hooks/useBudgetWindow'; @@ -97,11 +98,11 @@ export default function BudgetWindowSummary({
) : failed ? ( - - The budget window could not be calculated. - + budget.refetch()} + /> ) : (
)}
- + {/* A failed window has nothing to see yet; its retry is on the left. */} + {!failed && ( + + )} ); } diff --git a/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx b/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx index f451683a5..75234373c 100644 --- a/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx +++ b/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx @@ -77,13 +77,30 @@ describe('BudgetWindowSummary', () => { expect(onOpen).toHaveBeenCalledTimes(1); }); - test('given the window failed then the overview says so', async () => { + test('given the window failed then the overview says so, without a progress link', async () => { mockFetchBudgetWindow.mockResolvedValue({ status: 'error', result: null, error: 'Failed' }); renderSummary(); - expect( - await screen.findByText('The budget window could not be calculated.') - ).toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'The budget window could not be calculated' + ); + expect(screen.queryByRole('button', { name: /see its progress/i })).not.toBeInTheDocument(); + }); + + test('given the window failed then try again asks for it again', async () => { + // Given + mockFetchBudgetWindow + .mockResolvedValueOnce({ status: 'error', result: null, error: 'Failed' }) + .mockResolvedValue({ status: 'computing', result: null, progress: 40 }); + const user = userEvent.setup(); + renderSummary(); + + // When + await user.click(await screen.findByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText('Calculating · 40%')).toBeInTheDocument(); + expect(mockFetchBudgetWindow).toHaveBeenCalledTimes(2); }); }); From e242ff1a9a38ae518f1a81e5fc9f2b23dec0dade Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:57:40 -0400 Subject: [PATCH 15/27] Bill report: placeholder, feed failure and metadata retry While the feed loads, a placeholder in the report's shape holds the page. A failed feed says so with Try again; a bill the feed does not carry, or a deployment with no feed, says so with the next step. When the model's metadata fails, the full results say so and ask for it again; a failed full run uses the same error state. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/BillReportStatus.tsx | 66 ++++++++++++ .../flagship/report/ReportSkeleton.tsx | 101 ++++++++++++++++++ app/src/pages/flagship/BillReport.page.tsx | 49 +++++---- .../flagship/report/BillReportStatus.test.tsx | 59 ++++++++++ .../flagship/report/ReportSkeleton.test.tsx | 38 +++++++ .../unit/pages/flagship/BillReport.test.tsx | 54 +++++++++- 6 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 app/src/components/flagship/report/BillReportStatus.tsx create mode 100644 app/src/components/flagship/report/ReportSkeleton.tsx create mode 100644 app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx create mode 100644 app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx diff --git a/app/src/components/flagship/report/BillReportStatus.tsx b/app/src/components/flagship/report/BillReportStatus.tsx new file mode 100644 index 000000000..a1e4ec000 --- /dev/null +++ b/app/src/components/flagship/report/BillReportStatus.tsx @@ -0,0 +1,66 @@ +import ReportSkeleton from '@/components/flagship/report/ReportSkeleton'; +import { EmptyState, ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +/** + * A bill's report before there is a bill to show: the feed still + * loading, the feed failed or not configured, or a bill the feed does + * not carry. Only a failed feed is worth asking again. + */ +export default function BillReportStatus({ + isLoading, + isError, + isConfigured, + onRetry, +}: { + isLoading: boolean; + isError: boolean; + isConfigured: boolean; + onRetry: () => unknown; +}) { + const nav = useAppNavigate(); + const countryId = useCurrentCountry(); + + if (isLoading) { + return ; + } + + const toBills = () => nav.push(`/${countryId}/reforms`); + + return ( +
+ {isError ? ( + + All bills + + } + /> + ) : isConfigured ? ( + + All bills + + } + /> + ) : ( + // Without a feed there are no bills to go back to; a reform can still be built. + nav.push(`/${countryId}/build`)}> + Build a reform + + } + /> + )} +
+ ); +} diff --git a/app/src/components/flagship/report/ReportSkeleton.tsx b/app/src/components/flagship/report/ReportSkeleton.tsx new file mode 100644 index 000000000..2eeb22ba9 --- /dev/null +++ b/app/src/components/flagship/report/ReportSkeleton.tsx @@ -0,0 +1,101 @@ +import { SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; +import { colors, spacing } from '@/designTokens'; + +/** On the teal cover, placeholders are a light wash rather than gray. */ +const ON_COVER = { background: `${colors.white}2e` }; + +/** One of the report's section tabs, by its rough label width. */ +const TAB_WIDTHS = [64, 116, 64, 72, 76]; + +/** + * A report on its way, laid out as the report: the teal cover with its + * title, the section tabs, and the overview's summary, changes and + * headline tiles — so the report takes its place without a jump. + */ +export default function ReportSkeleton({ label }: { label: string }) { + return ( + +
+
+ + + +
+ +
+ {TAB_WIDTHS.map((width, index) => ( + + ))} +
+ +
+ + +
+ +
+ + {[0, 1, 2].map((row) => ( +
+ + +
+ ))} +
+ +
+ {[0, 1, 2].map((tile) => ( +
+ + +
+ ))} +
+
+
+ ); +} diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 3987468b7..0ebde7f63 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -4,12 +4,14 @@ import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { billRunYear, type TrackedBill } from '@/api/billFeed'; import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; +import BillReportStatus from '@/components/flagship/report/BillReportStatus'; import DistrictImpactCard, { type CongressionalMode, } from '@/components/flagship/report/DistrictImpactCard'; import ReportView from '@/components/flagship/report/ReportView'; import RunProgress from '@/components/flagship/report/RunProgress'; import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts'; +import { ErrorState } from '@/components/flagship/states'; import { BillValidationSection, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Button, Stack, Text } from '@/components/ui'; import { getDurationForCountry } from '@/constants/calculationDurations'; @@ -17,6 +19,7 @@ import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistr import { colors, spacing, typography } from '@/designTokens'; import { useBillEconomy, type BillEconomy } from '@/hooks/useBillEconomy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; @@ -111,10 +114,6 @@ const UNAVAILABLE_COPY: Partial void; }) { const countryId = useCurrentCountry(); + const metadata = useFlagshipMetadataStatus(); + if (runState === 'no-metadata') { + return ( + + + + ); + } const unavailable = UNAVAILABLE_COPY[runState]; if (unavailable) { return ( @@ -169,14 +181,13 @@ function FullResultsStatus({ } if (runState === 'error') { return ( - - - The full results could not be calculated. - - {economy.message && {economy.message}} - + + ); } @@ -220,7 +231,8 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) const parameters = useSelector((state: RootState) => state.metadata.parameters); const metadataLoaded = useSelector((state: RootState) => state.metadata.version !== null); const metadataError = useSelector((state: RootState) => state.metadata.error); - const { bills, isLoading } = useTrackedBills(countryId); + const feed = useTrackedBills(countryId); + const { bills } = feed; // The bill whose full run the reader started; another bill starts unrun. const [runRequestedFor, setRunRequestedFor] = useState(null); const runRequested = runRequestedFor === billId; @@ -264,11 +276,12 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) if (!bill) { return ( - - - {isLoading ? 'Loading the analysis…' : 'This bill is not in the current feed.'} - - + ); } diff --git a/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx new file mode 100644 index 000000000..8ab0b77a2 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import BillReportStatus from '@/components/flagship/report/BillReportStatus'; + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +const FEED = { isLoading: false, isError: false, isConfigured: true }; + +describe('BillReportStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given the feed is loading then the report placeholder holds its place', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the feed failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the bill feed'); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a bill the feed does not carry then all bills is the way back', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'All bills' })); + + expect(screen.getByText('This bill is not in the feed.')).toBeInTheDocument(); + expect(mockPush).toHaveBeenCalledWith('/us/reforms'); + }); + + test('given no feed configured then it says so and offers Build', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Build a reform' })); + + expect(screen.getByText('The bill feed is not configured here.')).toBeInTheDocument(); + expect(mockPush).toHaveBeenCalledWith('/us/build'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx new file mode 100644 index 000000000..c3faf169d --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import ReportSkeleton from '@/components/flagship/report/ReportSkeleton'; +import RunProgress, { RunProgressSkeleton } from '@/components/flagship/report/RunProgress'; + +describe('ReportSkeleton', () => { + test('given a report on its way then one busy status names it, with no headings yet', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toHaveAttribute( + 'aria-busy', + 'true' + ); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); +}); + +describe('RunProgressSkeleton', () => { + test('given a run not yet started then its card stands in, named for what loads', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading report' })).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + test('given the run then starts then the card has the same frame as its placeholder', () => { + const { container: placeholder } = render(); + const { container: run } = render( + + ); + + const frame = (element: Element) => { + const style = (element.firstElementChild as HTMLElement).style; + return [style.maxWidth, style.padding, style.border, style.borderRadius]; + }; + expect(frame(placeholder)).toEqual(frame(run)); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index 52e6bcb69..f0cf6047a 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -6,6 +6,7 @@ import BillReportPage from '@/pages/flagship/BillReport.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; @@ -52,6 +53,12 @@ vi.mock('@/libs/flagship/billMetrics', async (importOriginal) => { }); const mockUseBillEconomy = vi.fn(); +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); vi.mock('@/hooks/useBillEconomy', () => ({ useBillEconomy: (...args: unknown[]) => mockUseBillEconomy(...args), @@ -215,7 +222,7 @@ describe('BillReportPage', () => { await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); await user.click(screen.getByRole('button', { name: 'Load full results' })); - expect(screen.getByText('The full results could not be calculated.')).toBeInTheDocument(); + expect(screen.getByText('The full results could not be calculated')).toBeInTheDocument(); expect(screen.getByText('Worker lost')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Try again' })); @@ -485,6 +492,49 @@ describe('BillReportPage', () => { test('given an unknown bill then says it is not in the feed', async () => { renderReport('missing-bill'); - expect(await screen.findByText('This bill is not in the current feed.')).toBeInTheDocument(); + expect(await screen.findByText('This bill is not in the feed.')).toBeInTheDocument(); + }); + + test('given the feed is loading then the report placeholder holds its place', () => { + mockFetchTrackerBills.mockReturnValue(new Promise(() => {})); + + renderReport(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toBeInTheDocument(); + }); + + test('given the feed failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + mockFetchTrackerBills.mockRejectedValue(new Error('tracker down')); + renderReport(); + // The feed retries once before failing, so give it past the retry delay. + await screen.findByText('Could not load the bill feed', {}, { timeout: 5000 }); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect( + await screen.findByRole('heading', { level: 1, name: TRACKED_BILL.title }) + ).toBeInTheDocument(); + }); + + test('given the model metadata failed then the full results say so and ask for it again', async () => { + // Given + const user = userEvent.setup(); + mockFetchMetadata.mockReturnValue(new Promise(() => {})); + seedMetadataError(); + renderReport(); + await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the model’s parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(screen.queryByText('Could not load the model’s parameters')).not.toBeInTheDocument(); }); }); From 1c7430a2922c07e9df3d940bb12ba6a64282dc41 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 15:00:23 -0400 Subject: [PATCH 16/27] Report: a failed policy does not hold up the run A policy that fails to load only costs the provision detail, so the report keeps showing its run instead of the load error. Try again still asks for it. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useFlagshipReport.ts | 12 ++++----- .../fixtures/hooks/useFlagshipReportMocks.ts | 3 +++ .../unit/hooks/useFlagshipReport.test.tsx | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index b08ae1c84..7ba439685 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -28,7 +28,11 @@ export interface FlagshipReportData { simulations: Simulation[]; policies: Policy[]; isLoading: boolean; - /** Why the report, a simulation or a policy failed to load; null while none has. */ + /** + * Why the report or one of its simulations failed to load; null while + * neither has. A policy that fails only costs the provision detail, so + * it does not count: the run goes on without it. + */ error: Error | null; /** The report id resolves to no report (a bad or deleted link). */ notFound: boolean; @@ -100,11 +104,7 @@ export function useFlagshipReport(id: string): FlagshipReportData { retry: async () => undefined, }; } - const error = - (reportError as Error | null) ?? - (simulationResults.error as Error | null) ?? - (policyResults.error as Error | null) ?? - null; + const error = (reportError as Error | null) ?? (simulationResults.error as Error | null) ?? null; return { report, simulations, diff --git a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts index 12190c773..0d894f85b 100644 --- a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts +++ b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts @@ -18,6 +18,9 @@ export const SIMULATION_UNAVAILABLE_ERROR = new Error( `Failed to fetch simulation ${REFORM_SIMULATION_ID}: 503 Service Unavailable` ); +/** As fetchPolicyById throws when the API is down. */ +export const POLICY_UNAVAILABLE_ERROR = new Error('Failed to fetch policy 98557'); + export const EXISTING_REPORT_METADATA: ReportMetadata = { id: Number(EXISTING_REPORT_ID), country_id: 'us', diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx index a5ae3c743..58c296185 100644 --- a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -5,13 +5,16 @@ import { Provider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CountryProvider } from '@/contexts/CountryContext'; import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; import { BASELINE_SIMULATION_ID, EXISTING_REPORT_ID, EXISTING_REPORT_METADATA, MISSING_REPORT_ID, nationwideSimulationMetadata, + POLICY_UNAVAILABLE_ERROR, REFORM_SIMULATION_ID, REPORT_NOT_FOUND_ERROR, SIMULATION_UNAVAILABLE_ERROR, @@ -19,6 +22,7 @@ import { const mockFetchReportById = vi.fn(); const mockFetchSimulationById = vi.fn(); +const mockFetchPolicyById = vi.fn(); vi.mock('@/api/report', async (importOriginal) => ({ ...(await importOriginal()), @@ -30,6 +34,11 @@ vi.mock('@/api/simulation', async (importOriginal) => ({ fetchSimulationById: (...args: unknown[]) => mockFetchSimulationById(...args), })); +vi.mock('@/api/policy', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), +})); + // Old association links are not under test here. vi.mock('@/hooks/useUserReports', () => ({ useUserReportById: () => ({ report: undefined, simulations: [], policies: [], isLoading: false }), @@ -53,6 +62,7 @@ function renderReportHook(id: string) { describe('useFlagshipReport', () => { beforeEach(() => { vi.clearAllMocks(); + store.dispatch(clearMetadata()); mockFetchReportById.mockResolvedValue(EXISTING_REPORT_METADATA); mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => nationwideSimulationMetadata(id, id === BASELINE_SIMULATION_ID ? '2' : '98557') @@ -99,6 +109,21 @@ describe('useFlagshipReport', () => { expect(result.current.isLoading).toBe(false); }); + test('given a policy fails to load then the run is not held up by it', async () => { + // Given: policies load once the model's metadata is in. + seedDeductionMetadata(); + mockFetchPolicyById.mockRejectedValue(POLICY_UNAVAILABLE_ERROR); + + // When + const { result } = renderReportHook(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(mockFetchPolicyById).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.simulations).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); + test('given a failed simulation then retry asks for it again and the error clears', async () => { // Given mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { From beb82e3a4388ae2bb2d757604fdeaf97ce909dd0 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 15:17:44 -0400 Subject: [PATCH 17/27] Shell: one menu and fingertip controls on a phone On a phone the top bar keeps the brand, the draft count and one menu that holds both sections and the website links. The shell's padding is 16px, its height follows the visible screen, and buttons, inputs, selects, tabs, picker buttons and segmented options are at least 40px tall. The side panel plane moves under the content. Wider screens keep today's bar and sizes. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/FlagshipShell.tsx | 28 +++++- .../components/flagship/FlagshipTopBar.tsx | 97 +++++++++++++++---- .../flagship/FlagshipTopBar.test.tsx | 33 +++++++ 3 files changed, 133 insertions(+), 25 deletions(-) diff --git a/app/src/components/flagship/FlagshipShell.tsx b/app/src/components/flagship/FlagshipShell.tsx index 0cfc0a455..b1372ea4f 100644 --- a/app/src/components/flagship/FlagshipShell.tsx +++ b/app/src/components/flagship/FlagshipShell.tsx @@ -5,18 +5,33 @@ interface FlagshipShellProps { children: React.ReactNode; } +/** + * On a phone, the shared controls — buttons (also those with a tooltip), + * inputs, selects, tabs and the buttons that open a menu or a picker — + * are at least 40px tall, the size of a fingertip, and an icon button as + * wide. A segmented control's options are as tall. Wider screens keep + * each control's own size. + */ +const PHONE_TOUCH_TARGETS = [ + 'tw:max-sm:[&_:is([data-slot=button],button[data-slot=tooltip-trigger],[data-slot=input],[data-slot=select-trigger],[data-slot=tabs-trigger],button[aria-haspopup])]:min-h-10', + 'tw:max-sm:[&_:is([data-slot=button],button[data-slot=tooltip-trigger])]:min-w-10', + 'tw:max-sm:[&_[data-slot=segmented-control]]:h-12', +].join(' '); + /** * The flagship shell: a slim top bar (brand, sections, website links) * over the content area and the right plane for side panels. The bar - * is the same on every viewport, so content always has the full width. + * stays one slim row on every viewport, so content always has the full + * width. On a phone the plane moves under the content, where a side + * panel keeps a slim bar across the bottom (see SidePanel). */ export default function FlagshipShell({ children }: FlagshipShellProps) { return ( -
+
-
-
+
+
{children}
{/* The right plane: SidePanel portals its content here, so the @@ -24,7 +39,10 @@ export default function FlagshipShell({ children }: FlagshipShellProps) { content, full height by construction — rather than a floating box inside it. Empty (zero width) on pages without a companion. */} -
+
); diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index 903789865..c804d6a55 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -1,9 +1,16 @@ import { useEffect, useState } from 'react'; -import { IconAdjustments, IconDotsVertical, IconFilePencil, IconGavel } from '@tabler/icons-react'; +import { + IconAdjustments, + IconDotsVertical, + IconFilePencil, + IconGavel, + IconMenu2, +} from '@tabler/icons-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui'; import { WEBSITE_URL } from '@/constants'; @@ -25,8 +32,8 @@ const FLAGSHIP_TOP_BAR_HEIGHT = 56; /** * The flagship shell's navigation: brand, the two sections, and the * website links, in one slim bar across the top. Two destinations do - * not earn a sidebar's width, and they fit a phone's bar as they are, - * so there is no drawer to open on any viewport. + * not earn a sidebar's width. On a phone the sections and the links + * fold into one menu, so the bar keeps room for the brand and the draft. */ export default function FlagshipTopBar() { const nav = useAppNavigate(); @@ -45,6 +52,12 @@ export default function FlagshipTopBar() { useEffect(() => { setPendingSlug(null); }, [location.pathname]); + const isActive = (slug: string) => + pendingSlug ? pendingSlug === slug : location.pathname.includes(`/${slug}`); + const goTo = (slug: string) => { + setPendingSlug(slug); + nav.push(`/${countryId}/${slug}`); + }; // Away from Build, an unfinished draft stays one click away. const draft = useDraftReform(); @@ -52,6 +65,7 @@ export default function FlagshipTopBar() { draft && draft.countryId === countryId && !location.pathname.includes('/build') ? draft.provisions.length : 0; + const parameterWord = draftCount === 1 ? 'parameter' : 'parameters'; const moreLinks = [ { label: 'Research', href: `${WEBSITE_URL}/${countryId}/research` }, @@ -64,29 +78,24 @@ export default function FlagshipTopBar() { return (
- + -
+ ) : undefined + } customBaselineLabel={customBaselineLabel} year={report?.year ?? ''} region={region} diff --git a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts index 0d894f85b..b7bdd21e0 100644 --- a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts +++ b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts @@ -1,3 +1,5 @@ +import type { Report } from '@/types/ingredients/Report'; +import type { Simulation } from '@/types/ingredients/Simulation'; import type { ReportMetadata } from '@/types/metadata/reportMetadata'; import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; @@ -13,6 +15,10 @@ export const REFORM_SIMULATION_ID = '502'; export const REPORT_NOT_FOUND_ERROR = new Error( `Failed to fetch report ${MISSING_REPORT_ID}: 404 Not Found` ); +/** As fetchReportById throws when the API is down. */ +export const REPORT_UNAVAILABLE_ERROR = new Error( + `Failed to fetch report ${EXISTING_REPORT_ID}: 503 Service Unavailable` +); /** As fetchSimulationById throws when the API is down. */ export const SIMULATION_UNAVAILABLE_ERROR = new Error( `Failed to fetch simulation ${REFORM_SIMULATION_ID}: 503 Service Unavailable` @@ -42,3 +48,32 @@ export const nationwideSimulationMetadata = (id: string, policyId: string): Simu status: 'pending', output: null, }); + +/** The report the hook hands on once it is in. */ +export const EXISTING_REPORT: Report = { + id: EXISTING_REPORT_ID, + label: 'Bigger CTC', + countryId: 'us', + year: '2026', + apiVersion: 'v1', + simulationIds: [BASELINE_SIMULATION_ID, REFORM_SIMULATION_ID], + status: 'pending', +}; + +const nationwideSimulation = (id: string, policyId: string): Simulation => ({ + id, + countryId: 'us', + apiVersion: 'v1', + policyId, + populationId: 'us', + populationType: 'geography', + label: null, + isCreated: true, + status: 'pending', +}); + +/** Its baseline and reform simulations, once they are in. */ +export const EXISTING_SIMULATIONS: Simulation[] = [ + nationwideSimulation(BASELINE_SIMULATION_ID, '2'), + nationwideSimulation(REFORM_SIMULATION_ID, '98557'), +]; diff --git a/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts b/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts new file mode 100644 index 000000000..d3e5e227b --- /dev/null +++ b/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts @@ -0,0 +1,2 @@ +/** As fetchTrackerBills throws when the tracker is down. */ +export const TRACKER_UNAVAILABLE_ERROR = new Error('Tracker feed bills failed: 503'); diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx index 58c296185..34a051c79 100644 --- a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CountryProvider } from '@/contexts/CountryContext'; import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { reportKeys } from '@/libs/queryKeys'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; @@ -17,6 +18,7 @@ import { POLICY_UNAVAILABLE_ERROR, REFORM_SIMULATION_ID, REPORT_NOT_FOUND_ERROR, + REPORT_UNAVAILABLE_ERROR, SIMULATION_UNAVAILABLE_ERROR, } from '@/tests/fixtures/hooks/useFlagshipReportMocks'; @@ -56,7 +58,7 @@ function renderReportHook(id: string) { ); - return renderHook(() => useFlagshipReport(id), { wrapper }); + return { queryClient, ...renderHook(() => useFlagshipReport(id), { wrapper }) }; } describe('useFlagshipReport', () => { @@ -91,6 +93,47 @@ describe('useFlagshipReport', () => { expect(mockFetchReportById).toHaveBeenCalledTimes(1); }); + test('given the report is in then a refetch that fails is not a failed load', async () => { + // Given + const { result, rerender, queryClient } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + mockFetchReportById.mockRejectedValue(REPORT_UNAVAILABLE_ERROR); + + // When — e.g. the reader comes back to the tab while the run goes on + await act(async () => { + await queryClient.refetchQueries({ queryKey: reportKeys.byId(EXISTING_REPORT_ID) }); + }); + // A render after the refetch reads the query as it now stands. + rerender(); + + // Then + expect(queryClient.getQueryState(reportKeys.byId(EXISTING_REPORT_ID))?.error).toBe( + REPORT_UNAVAILABLE_ERROR + ); + expect(result.current.error).toBeNull(); + expect(result.current.report).toBeDefined(); + }); + + test('given the report is in then a refetch that finds nothing does not make it not found', async () => { + // Given + const { result, rerender, queryClient } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + mockFetchReportById.mockRejectedValue(REPORT_NOT_FOUND_ERROR); + + // When + await act(async () => { + await queryClient.refetchQueries({ queryKey: reportKeys.byId(EXISTING_REPORT_ID) }); + }); + rerender(); + + // Then + expect(queryClient.getQueryState(reportKeys.byId(EXISTING_REPORT_ID))?.error).toBe( + REPORT_NOT_FOUND_ERROR + ); + expect(result.current.notFound).toBe(false); + expect(result.current.error).toBeNull(); + }); + test('given a simulation fails to load then the error says so, and the report is found', async () => { // Given mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { diff --git a/app/src/tests/unit/hooks/useTrackedBills.test.tsx b/app/src/tests/unit/hooks/useTrackedBills.test.tsx new file mode 100644 index 000000000..a9a6a94f3 --- /dev/null +++ b/app/src/tests/unit/hooks/useTrackedBills.test.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useTrackedBills } from '@/hooks/useTrackedBills'; +import { TRACKER_UNAVAILABLE_ERROR } from '@/tests/fixtures/hooks/useTrackedBillsMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockFetchTrackerBills = vi.fn(); + +vi.mock('@/api/billFeed', async (importOriginal) => ({ + ...(await importOriginal()), + fetchTrackerBills: () => mockFetchTrackerBills(), +})); + +const FEED_KEY = ['tracker-bills']; + +function renderBillsHook() { + // No retry delay, so the feed's own retry settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, ...renderHook(() => useTrackedBills('us'), { wrapper }) }; +} + +describe('useTrackedBills', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given the feed fails before any bills are in then it is an error', async () => { + // Given + mockFetchTrackerBills.mockRejectedValue(TRACKER_UNAVAILABLE_ERROR); + + // When + const { result } = renderBillsHook(); + + // Then + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.bills).toEqual([]); + }); + + test('given bills are in then a refetch that fails keeps them, with no error', async () => { + // Given + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + const { result, rerender, queryClient } = renderBillsHook(); + await waitFor(() => expect(result.current.bills).toHaveLength(1)); + mockFetchTrackerBills.mockRejectedValue(TRACKER_UNAVAILABLE_ERROR); + + // When — e.g. the reader comes back to the tab after the feed went stale + await act(async () => { + await queryClient.refetchQueries({ queryKey: FEED_KEY }); + }); + // A render after the refetch reads the query as it now stands. + rerender(); + + // Then + expect(queryClient.getQueryState(FEED_KEY)?.error).toBe(TRACKER_UNAVAILABLE_ERROR); + expect(result.current.isError).toBe(false); + expect(result.current.bills).toEqual([TRACKED_BILL]); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/parameterGroups.test.ts b/app/src/tests/unit/libs/flagship/parameterGroups.test.ts index ed967c7e6..d84d26f41 100644 --- a/app/src/tests/unit/libs/flagship/parameterGroups.test.ts +++ b/app/src/tests/unit/libs/flagship/parameterGroups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { bracketFieldLabel, + groupKeyFromPath, humanizeSegment, parameterGroup, } from '@/libs/flagship/parameterGroups'; @@ -95,6 +96,28 @@ describe('parameterGroup', () => { }); }); +describe('groupKeyFromPath', () => { + test('given a grouped member then its key is the group key the index gives', () => { + for (const path of [ + 'gov.irs.deductions.standard.amount.SINGLE', + 'gov.irs.credits.eitc.max[1].amount', + 'gov.usda.snap.allotment.10', + ]) { + expect(groupKeyFromPath(path)).toBe(parameterGroup(path, PARAMETERS, DRAFTABLE)?.key); + } + }); + + test('given members of one setting then they share a key', () => { + expect(groupKeyFromPath('gov.irs.credits.eitc.max[0].threshold')).toBe( + groupKeyFromPath('gov.irs.credits.eitc.max[1].amount') + ); + }); + + test('given an ordinary parameter then its key is its own path', () => { + expect(groupKeyFromPath('gov.irs.credits.ctc.amount')).toBe('gov.irs.credits.ctc.amount'); + }); +}); + describe('humanizeSegment', () => { test('given a shouted enum then it reads as a phrase; sentence case stays', () => { expect(humanizeSegment('HEAD_OF_HOUSEHOLD')).toBe('Head of household'); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index f0cf6047a..fe6ac3baf 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -528,13 +528,13 @@ describe('BillReportPage', () => { seedMetadataError(); renderReport(); await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); - expect(screen.getByRole('alert')).toHaveTextContent('Could not load the model’s parameters'); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); // When await user.click(screen.getByRole('button', { name: 'Try again' })); // Then expect(mockFetchMetadata).toHaveBeenCalledWith('us'); - expect(screen.queryByText('Could not load the model’s parameters')).not.toBeInTheDocument(); + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx index 9fe556945..9873d541a 100644 --- a/app/src/tests/unit/pages/flagship/Build.test.tsx +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -1,11 +1,15 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, userEvent } from '@test-utils'; +import { act, render, screen, userEvent } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { addDraftProvision, clearDraftReform, startDraftReform } from '@/libs/draftReform'; import BuildPage from '@/pages/flagship/Build.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; -import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + JOINT_PATH, + seedDeductionMetadata, + SINGLE_PATH, +} from '@/tests/fixtures/components/flagship/draftEditorFixtures'; import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; const mockFetchMetadata = vi.fn(); @@ -90,6 +94,26 @@ describe('BuildPage', () => { expect(screen.queryByRole('button', { name: /open base amount/i })).not.toBeInTheDocument(); }); + test('given breakdown members before metadata loads then the placeholder has the table rows', () => { + // Given — two filing statuses of one deduction share a row in the table + startDraftReform('us', 'manual'); + addDraftProvision('us', CTC); + for (const path of [SINGLE_PATH, JOINT_PATH]) { + addDraftProvision('us', { ...CTC, path, breadcrumb: 'IRS → Deductions → Standard' }); + } + renderPage(); + const placeholder = screen.getByRole('status', { name: 'Loading parameters' }); + // The header, a row per table row, and the add bar at the foot. + const placeholderRows = placeholder.firstElementChild!.children.length - 2; + + // When + act(() => seedDeductionMetadata()); + + // Then + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(placeholderRows); + expect(placeholderRows).toBe(2); + }); + test('given metadata failed then the page says so instead of loading for good', () => { // Given seedMetadataError(); diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx index 3304d2200..9c8307fc6 100644 --- a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -3,15 +3,23 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; import { + EXISTING_REPORT, EXISTING_REPORT_ID, + EXISTING_SIMULATIONS, MISSING_REPORT_ID, REPORT_NOT_FOUND_ERROR, SIMULATION_UNAVAILABLE_ERROR, } from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockUseFlagshipReport = vi.fn(); const mockRetry = vi.fn(); +const mockCalcStatus = vi.fn(); +const mockFetchMetadata = vi.fn(); vi.mock('@/hooks/useFlagshipReport', () => ({ isApiReportId: () => true, @@ -19,14 +27,17 @@ vi.mock('@/hooks/useFlagshipReport', () => ({ })); vi.mock('@/hooks/useCalculationStatus', () => ({ - useCalculationStatus: () => ({ - status: 'idle', - isComplete: false, - isError: false, - result: null, - }), + useCalculationStatus: () => mockCalcStatus(), })); +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); + +const IDLE_STATUS = { status: 'idle', isComplete: false, isError: false, result: null }; +const PENDING_STATUS = { ...IDLE_STATUS, status: 'pending', progress: 20 }; + vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ useStartCalculationOnLoad: () => undefined, })); @@ -71,6 +82,11 @@ function renderPage(userReportId: string) { describe('FlagshipReportPage load states', () => { beforeEach(() => { vi.clearAllMocks(); + // Cleared before each test, not after: the last page is still mounted then. + store.dispatch(clearMetadata()); + mockCalcStatus.mockReturnValue(IDLE_STATUS); + // A retried metadata fetch stays in flight. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); }); test('given the report is loading then the run card placeholder holds its place', () => { @@ -78,7 +94,47 @@ describe('FlagshipReportPage load states', () => { renderPage(EXISTING_REPORT_ID); - expect(screen.getByRole('status', { name: 'Loading report' })).toBeInTheDocument(); + // One status, the placeholder's own: no live region around it. + expect(screen.getAllByRole('status')).toEqual([ + screen.getByRole('status', { name: 'Loading report' }), + ]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the run is calculating then the run card is the one status', () => { + mockCalcStatus.mockReturnValue(PENDING_STATUS); + mockUseFlagshipReport.mockReturnValue( + reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + ); + + renderPage(EXISTING_REPORT_ID); + + const [status, ...others] = screen.getAllByRole('status'); + expect(status).toHaveTextContent('Calculating the nationwide results'); + expect(others).toHaveLength(0); + }); + + test('given the results are in and the parameters failed then the overview says so and asks again', async () => { + // Given + const user = userEvent.setup(); + seedMetadataError(); + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + mockUseFlagshipReport.mockReturnValue( + reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + ); + renderPage(EXISTING_REPORT_ID); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); From c7c3660000a6b93089f9620e613dab5265370016 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 15:40:59 -0400 Subject: [PATCH 23/27] Shell: phone layout only on v3 pages; drawer is a modal The shell also hosts the legacy v2 pages while the flag is on. Those now keep the classes they had before the phone work (h-screen, 24px padding, no touch-target rule), and only Build, Reforms, a report and a bill report get the phone layout. A test covers both sets of routes. On a phone the open side panel is a modal dialog with a close icon, and the page and top bar under it are inert. A test shows that Escape closes a picker inside the drawer and leaves the drawer open. The state filter's choices and the year list are a fingertip tall, and the filter's search is 16px. The policy tree starts under its close button. The width mock reads em and rem, and throws on a width query it cannot read. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/FlagshipShell.tsx | 40 ++++-- app/src/components/flagship/ScopePicker.tsx | 10 +- app/src/components/flagship/SidePanel.tsx | 57 +++++++-- .../flagship/reform/AddParameterBar.tsx | 3 +- .../flagship/reform/DraftHeader.tsx | 9 +- app/src/libs/flagship/shellRoutes.ts | 13 ++ app/src/libs/flagship/viewport.ts | 5 + .../fixtures/hooks/useMediaQueryMocks.ts | 25 +++- .../flagship/FlagshipShell.test.tsx | 117 ++++++++++++++++++ .../components/flagship/SidePanel.test.tsx | 42 ++++++- .../unit/libs/flagship/shellRoutes.test.ts | 31 +++++ 11 files changed, 319 insertions(+), 33 deletions(-) create mode 100644 app/src/libs/flagship/shellRoutes.ts create mode 100644 app/src/tests/unit/components/flagship/FlagshipShell.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/shellRoutes.test.ts diff --git a/app/src/components/flagship/FlagshipShell.tsx b/app/src/components/flagship/FlagshipShell.tsx index b1372ea4f..bafeb57df 100644 --- a/app/src/components/flagship/FlagshipShell.tsx +++ b/app/src/components/flagship/FlagshipShell.tsx @@ -1,3 +1,5 @@ +import { useAppPathname } from '@/contexts/LocationContext'; +import { isFlagshipPagePath } from '@/libs/flagship/shellRoutes'; import FlagshipTopBar from './FlagshipTopBar'; import { SIDE_PANEL_SLOT_ID } from './SidePanel'; @@ -18,6 +20,28 @@ const PHONE_TOUCH_TARGETS = [ 'tw:max-sm:[&_[data-slot=segmented-control]]:h-12', ].join(' '); +/** + * The layout of a v3 page: the touch targets and a closer page edge on a + * phone, where the right plane moves under the content. + */ +const FLAGSHIP_LAYOUT = { + root: `tw:h-dvh tw:overflow-hidden tw:flex tw:flex-col ${PHONE_TOUCH_TARGETS}`, + row: 'tw:flex tw:flex-1 tw:min-h-0 tw:max-sm:flex-col', + main: 'tw:flex-1 tw:min-w-0 tw:min-h-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-lg tw:sm:p-2xl tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:max-sm:h-auto tw:bg-gray-50', +}; + +/** + * The legacy v2 pages the shell also hosts while the flag is on keep the + * layout they have always had, on every screen. + */ +const LEGACY_LAYOUT = { + root: 'tw:h-screen tw:overflow-hidden tw:flex tw:flex-col', + row: 'tw:flex tw:flex-1 tw:min-h-0', + main: 'tw:flex-1 tw:min-w-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-[24px] tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:bg-gray-50', +}; + /** * The flagship shell: a slim top bar (brand, sections, website links) * over the content area and the right plane for side panels. The bar @@ -26,23 +50,21 @@ const PHONE_TOUCH_TARGETS = [ * panel keeps a slim bar across the bottom (see SidePanel). */ export default function FlagshipShell({ children }: FlagshipShellProps) { + const layout = isFlagshipPagePath(useAppPathname()) ? FLAGSHIP_LAYOUT : LEGACY_LAYOUT; + + // data-flagship-shell: where a phone's drawer finds the page it covers (see SidePanel). return ( -
+
-
-
- {children} -
+
+
{children}
{/* The right plane: SidePanel portals its content here, so the panel is a real column of the shell — outside the scrolling content, full height by construction — rather than a floating box inside it. Empty (zero width) on pages without a companion. */} -
+
); diff --git a/app/src/components/flagship/ScopePicker.tsx b/app/src/components/flagship/ScopePicker.tsx index 273e97d58..ab3f026e7 100644 --- a/app/src/components/flagship/ScopePicker.tsx +++ b/app/src/components/flagship/ScopePicker.tsx @@ -147,9 +147,15 @@ export default function ScopePicker({ /> - + - + {/* 16px on a phone, so the browser does not zoom in to type. */} + No matching state {JURISDICTIONS.map((option) => renderItem(option, false))} diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx index 94f0287e7..df4c17be6 100644 --- a/app/src/components/flagship/SidePanel.tsx +++ b/app/src/components/flagship/SidePanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { IconChevronDown } from '@tabler/icons-react'; +import { IconChevronDown, IconX } from '@tabler/icons-react'; import { createPortal } from 'react-dom'; import { Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -19,6 +19,8 @@ import { PHONE_QUERY } from '@/libs/flagship/viewport'; * the panel renders in place. */ export const SIDE_PANEL_SLOT_ID = 'flagship-side-panel-slot'; +/** The shell's root (see FlagshipShell): what a phone's open drawer covers. */ +const SHELL_ROOT_SELECTOR = '[data-flagship-shell]'; const PANEL_WIDTH = 340; const SPINE_WIDTH = 40; @@ -37,7 +39,7 @@ const OVERLAY_BELOW = '(max-width: 1023px)'; * plane under the content (see FlagshipShell): folded, the panel is a * bar across the bottom; open, it fills the screen, as a drawer. */ -const BAR_HEIGHT = 48; +const BAR_HEIGHT = spacing['4xl']; /** Effects that must run before paint, without warning during SSR. */ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -173,6 +175,28 @@ export default function SidePanel({ return () => document.removeEventListener('keydown', onKeyDown); }, [phone, open, storageKey]); + // The drawer covers the shell: while it is open, the page and the top + // bar under it are out of reach of the keyboard and screen readers. + useEffect(() => { + const shell = slot?.closest(SHELL_ROOT_SELECTOR); + if (!phone || !open || !slot || !shell) { + return; + } + const covered: Element[] = []; + let node: Element = slot; + while (node !== shell && node.parentElement) { + const parent: Element = node.parentElement; + for (const sibling of Array.from(parent.children)) { + if (sibling !== node && !sibling.hasAttribute('inert')) { + sibling.setAttribute('inert', ''); + covered.push(sibling); + } + } + node = parent; + } + return () => covered.forEach((element) => element.removeAttribute('inert')); + }, [phone, open, slot]); + // The plane mirrors the left sidebar: the same flat surface and quiet // edge, with teal reserved for text — no colored bands. const titleColor = accent ? colors.primary[700] : colors.text.primary; @@ -256,6 +280,9 @@ export default function SidePanel({ >
- {meta && ( - - {meta} - + {(meta || phone) && ( + + {meta && ( + + {meta} + + )} + {/* A drawer shows how to close it. */} + {phone && } + )} {/* The body scrolls, not the page: the plane keeps its own height. */} diff --git a/app/src/components/flagship/reform/AddParameterBar.tsx b/app/src/components/flagship/reform/AddParameterBar.tsx index 83238ac89..3ff5dd8c4 100644 --- a/app/src/components/flagship/reform/AddParameterBar.tsx +++ b/app/src/components/flagship/reform/AddParameterBar.tsx @@ -232,6 +232,8 @@ export default function AddParameterBar({ {browseOpen && (
{yearOptions.map((option) => ( - + {option} ))} @@ -149,7 +153,8 @@ function YearsPicker({ value: String(years), label: years === 1 ? '1 year' : `${years} years`, }))} - // A fingertip tall on a phone. + // A fingertip tall on a phone. The popover portals out of the + // shell, so the shell's own phone sizes do not reach it. className="tw:max-sm:h-12" />
diff --git a/app/src/libs/flagship/shellRoutes.ts b/app/src/libs/flagship/shellRoutes.ts new file mode 100644 index 000000000..776451605 --- /dev/null +++ b/app/src/libs/flagship/shellRoutes.ts @@ -0,0 +1,13 @@ +/** + * The app v3 pages: Build, Reforms, a report and a bill report (and the + * Ask redirect), under a country (`/us/build`, `/uk/report/bill/abc`). + * With the flagship flag on, the shell also hosts the legacy v2 pages + * (`/us/report-output/…`, `/us/reports`, `/us/policies` …); those are not + * v3 pages and keep their own layout. + */ +const FLAGSHIP_PAGE_PATH = /^\/[^/]+\/(?:build|reforms|ask|report\/[^/]+)(?:[/?#]|$)/; + +/** Whether `pathname` is an app v3 page. */ +export function isFlagshipPagePath(pathname: string): boolean { + return FLAGSHIP_PAGE_PATH.test(pathname); +} diff --git a/app/src/libs/flagship/viewport.ts b/app/src/libs/flagship/viewport.ts index 0116cf125..80dddbd26 100644 --- a/app/src/libs/flagship/viewport.ts +++ b/app/src/libs/flagship/viewport.ts @@ -2,5 +2,10 @@ * A phone's screen: narrower than Tailwind's `sm` breakpoint (48rem), so * it matches where the `tw:max-sm:` classes apply. For the few layouts * that change by width in script rather than in CSS. + * + * Not the charts' MOBILE_BREAKPOINT_QUERY (`max-width: 48em`, see + * useChartDimensions): that one also matches a screen exactly 768px wide, + * where the `tw:max-sm:` classes no longer apply. Script and CSS must + * agree on which layout a 768px screen gets, so this one stops at 767px. */ export const PHONE_QUERY = '(max-width: 767px)'; diff --git a/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts b/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts index b18be991f..699eb963d 100644 --- a/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts +++ b/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts @@ -7,13 +7,26 @@ export const SCREEN_WIDTHS = { DESKTOP: 1280, } as const; -/** Whether a screen this wide matches a query's (min-width / max-width: Npx) parts. */ +/** CSS pixels in one unit of a width query; em and rem use the 16px default font size. */ +const PX_PER_UNIT: Record = { px: 1, em: 16, rem: 16 }; + +/** + * Whether a screen this wide matches a query's (min-width / max-width: N + * px | em | rem) parts. A width query in any other form throws, so a test + * never gets the desktop layout without a warning. + */ function matchesWidth(query: string, width: number): boolean { - const parts = [...query.matchAll(/\((min|max)-width:\s*(\d+)px\)/g)]; - return ( - parts.length > 0 && - parts.every(([, bound, px]) => (bound === 'min' ? width >= Number(px) : width <= Number(px))) - ); + const parts = [...query.matchAll(/\((min|max)-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)/g)]; + if (parts.length === 0) { + if (query.includes('width')) { + throw new Error(`mockScreenWidth cannot read the width query "${query}"`); + } + return false; + } + return parts.every(([, bound, size, unit]) => { + const px = Number(size) * PX_PER_UNIT[unit]; + return bound === 'min' ? width >= px : width <= px; + }); } /** diff --git a/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx b/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx new file mode 100644 index 000000000..0e05ab1ed --- /dev/null +++ b/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx @@ -0,0 +1,117 @@ +import { renderWithCountry, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import FlagshipShell from '@/components/flagship/FlagshipShell'; +import SidePanel from '@/components/flagship/SidePanel'; +import { clearDraftReform } from '@/libs/draftReform'; +import { mockScreenWidth, SCREEN_WIDTHS } from '@/tests/fixtures/hooks/useMediaQueryMocks'; + +/** The v3 pages, which get the phone layout. */ +const FLAGSHIP_PATHS = ['/us/build', '/us/reforms', '/us/report/abc123', '/uk/report/bill/hr-1']; + +/** The legacy v2 pages the shell also hosts while the flag is on. */ +const LEGACY_PATHS = [ + '/us/report-output/abc123', + '/us/report-output/abc123/overview', + '/us/reports', + '/us/reports/create', + '/us/simulations', + '/us/policies', + '/us/households', +]; + +/** The shell's classes before the phone layout, which legacy pages keep. */ +const LEGACY_CLASSES = { + root: 'tw:h-screen tw:overflow-hidden tw:flex tw:flex-col', + main: 'tw:flex-1 tw:min-w-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-[24px] tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:bg-gray-50', +}; + +function renderShell(path: string, children: React.ReactNode = 'Page content') { + const countryId = path.split('/')[1]; + const view = renderWithCountry({children}, countryId, path); + const root = view.container.querySelector('[data-flagship-shell]') as HTMLElement; + const slot = view.container.querySelector('#flagship-side-panel-slot') as HTMLElement; + return { ...view, root, slot }; +} + +describe('FlagshipShell', () => { + beforeEach(() => { + clearDraftReform(); + }); + + test.each(FLAGSHIP_PATHS)('given the v3 page %s then it gets the phone layout', (path) => { + // When + const { root, slot } = renderShell(path); + + // Then + expect(root.className).toContain('tw:max-sm:[&_[data-slot=segmented-control]]:h-12'); + expect(root).toHaveClass('tw:h-dvh'); + expect(screen.getByRole('main')).toHaveClass('tw:p-lg', 'tw:sm:p-2xl'); + expect(slot).toHaveClass('tw:max-sm:h-auto'); + }); + + test.each(LEGACY_PATHS)('given the legacy page %s then its layout is unchanged', (path) => { + // When + const { root, slot } = renderShell(path); + + // Then — no phone touch targets, padding or plane under the content + expect(root.className).toBe(LEGACY_CLASSES.root); + expect(root.className).not.toContain('max-sm'); + expect(screen.getByRole('main').className).toBe(LEGACY_CLASSES.main); + expect(slot.className).toBe(LEGACY_CLASSES.slot); + }); +}); + +describe('FlagshipShell on a phone', () => { + beforeEach(() => { + clearDraftReform(); + sessionStorage.clear(); + mockScreenWidth(SCREEN_WIDTHS.PHONE); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('given the side panel opens as a drawer then the page under it is out of reach', async () => { + // Given + const user = userEvent.setup(); + renderShell( + '/us/report/abc123', + +

panel body

+
+ ); + + // When + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // Then — a modal drawer; the page and the top bar are inert + expect(screen.getByRole('dialog', { name: 'Adjust parameters' })).toHaveAttribute( + 'aria-modal', + 'true' + ); + expect(screen.getByRole('main', { hidden: true })).toHaveAttribute('inert'); + expect(screen.getByRole('banner', { hidden: true })).toHaveAttribute('inert'); + }); + + test('given the drawer closes then the page is in reach again', async () => { + // Given + const user = userEvent.setup(); + renderShell( + '/us/report/abc123', + +

panel body

+
+ ); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // When + await user.keyboard('{Escape}'); + + // Then + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('main')).not.toHaveAttribute('inert'); + expect(screen.getByRole('banner')).not.toHaveAttribute('inert'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx index 472b0c474..219f7d9d2 100644 --- a/app/src/tests/unit/components/flagship/SidePanel.test.tsx +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -1,6 +1,7 @@ import { render, screen, userEvent } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import SidePanel from '@/components/flagship/SidePanel'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui'; import { mockScreenWidth, SCREEN_WIDTHS } from '@/tests/fixtures/hooks/useMediaQueryMocks'; describe('SidePanel', () => { @@ -226,6 +227,44 @@ describe('SidePanel on a phone', () => { expect(screen.getByRole('button', { name: /open draft reform/i })).toHaveFocus(); }); + test('given a phone and a picker open in the drawer then Escape closes only the picker', async () => { + // Given — a picker inside the open drawer, as the adjust panel's population picker + const user = userEvent.setup(); + render( + + + Pick a state + State list + + + ); + await user.click(screen.getByRole('button', { name: 'Pick a state' })); + expect(screen.getByText('State list')).toBeInTheDocument(); + + // When + await user.keyboard('{Escape}'); + + // Then — the picker closed; the drawer stays open + expect(screen.queryByText('State list')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Adjust parameters' })).toBeVisible(); + expect(screen.getByRole('button', { name: /collapse adjust parameters/i })).toBeVisible(); + }); + + test('given a phone and an open panel then it is a modal dialog', () => { + // Given / When + render( + +

panel body

+
+ ); + + // Then + expect(screen.getByRole('dialog', { name: 'Draft reform' })).toHaveAttribute( + 'aria-modal', + 'true' + ); + }); + test('given a wide screen then Escape leaves the panel open beside the content', async () => { // Given — the default: a column beside the content mockScreenWidth(SCREEN_WIDTHS.DESKTOP); @@ -239,8 +278,9 @@ describe('SidePanel on a phone', () => { // When await user.keyboard('{Escape}'); - // Then + // Then — a column, not a dialog expect(screen.getByText('panel body')).toBeVisible(); expect(screen.getByLabelText('Draft reform')).toHaveStyle({ position: 'absolute' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/libs/flagship/shellRoutes.test.ts b/app/src/tests/unit/libs/flagship/shellRoutes.test.ts new file mode 100644 index 000000000..80e9483dd --- /dev/null +++ b/app/src/tests/unit/libs/flagship/shellRoutes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; +import { isFlagshipPagePath } from '@/libs/flagship/shellRoutes'; + +describe('isFlagshipPagePath', () => { + test.each([ + '/us/build', + '/uk/build/', + '/us/reforms', + '/us/reforms?filter=yours', + '/us/ask', + '/us/report/abc123', + '/us/report/bill/hr-1', + ])('given the v3 page %s then it is a flagship page', (path) => { + expect(isFlagshipPagePath(path)).toBe(true); + }); + + test.each([ + '/us', + '/us/report-output/abc123', + '/us/report-output/abc123/overview', + '/us/reports', + '/us/reports/create', + '/us/simulations', + '/us/policies', + '/us/households', + '/us/report', + '/us/buildings', + ])('given the legacy or other page %s then it is not a flagship page', (path) => { + expect(isFlagshipPagePath(path)).toBe(false); + }); +}); From a1dc931a7fabeb73030ddad65155ce7481612af0 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:14:33 -0400 Subject: [PATCH 24/27] Report: a spinner while the run starts, not an empty card Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/RunProgress.tsx | 35 ++++++++++++------- app/src/pages/flagship/Report.page.tsx | 4 +-- .../flagship/report/ReportSkeleton.test.tsx | 26 +++++--------- .../pages/flagship/ReportLoadStates.test.tsx | 4 +-- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/app/src/components/flagship/report/RunProgress.tsx b/app/src/components/flagship/report/RunProgress.tsx index 9b91a3d33..aec6ed8c2 100644 --- a/app/src/components/flagship/report/RunProgress.tsx +++ b/app/src/components/flagship/report/RunProgress.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; -import { SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; -import { Progress, Stack, Text } from '@/components/ui'; +import { Progress, Spinner, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { formatElapsed } from '@/libs/flagship/reportStages'; @@ -15,7 +14,7 @@ function useElapsed(): number { return now - started; } -/** The run's card, shared with its placeholder so one replaces the other in place. */ +/** The run's card. */ const CARD_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', @@ -29,18 +28,28 @@ const CARD_STYLE: React.CSSProperties = { }; /** - * The run's card before there is a run to show — the report and its - * status still loading — at the size the run's bar takes once it starts. + * The wait before a run has a bar: the report and its status still + * loading. A spinner, not a card, so nothing stands empty on the page + * until the run's own card arrives. */ -export function RunProgressSkeleton({ label }: { label: string }) { +export function RunLoading({ label }: { label: string }) { return ( - -
- - -
- -
+
+ + + {label}… + +
); } diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 1b70cddf3..e68ac253d 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -7,7 +7,7 @@ import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; import ReportLoadError from '@/components/flagship/report/ReportLoadError'; import ReportView from '@/components/flagship/report/ReportView'; -import RunProgress, { RunProgressSkeleton } from '@/components/flagship/report/RunProgress'; +import RunProgress, { RunLoading } from '@/components/flagship/report/RunProgress'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; import { ErrorState } from '@/components/flagship/states'; import { trackRecordPrograms, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; @@ -223,7 +223,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep expectedMs={getDurationForCountry(report?.countryId ?? 'us')} /> ) : ( - + )} ); diff --git a/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx index c3faf169d..c45db6346 100644 --- a/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@test-utils'; import { describe, expect, test } from 'vitest'; import ReportSkeleton from '@/components/flagship/report/ReportSkeleton'; -import RunProgress, { RunProgressSkeleton } from '@/components/flagship/report/RunProgress'; +import { RunLoading } from '@/components/flagship/report/RunProgress'; describe('ReportSkeleton', () => { test('given a report on its way then one busy status names it, with no headings yet', () => { @@ -15,24 +15,14 @@ describe('ReportSkeleton', () => { }); }); -describe('RunProgressSkeleton', () => { - test('given a run not yet started then its card stands in, named for what loads', () => { - render(); +describe('RunLoading', () => { + test('given a run not yet started then a spinner stands in, named for what loads', () => { + render(); - expect(screen.getByRole('status', { name: 'Loading report' })).toBeInTheDocument(); + expect(screen.getAllByRole('status')).toEqual([ + screen.getByRole('status', { name: 'Loading report' }), + ]); + expect(screen.getByText('Loading report…')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - - test('given the run then starts then the card has the same frame as its placeholder', () => { - const { container: placeholder } = render(); - const { container: run } = render( - - ); - - const frame = (element: Element) => { - const style = (element.firstElementChild as HTMLElement).style; - return [style.maxWidth, style.padding, style.border, style.borderRadius]; - }; - expect(frame(placeholder)).toEqual(frame(run)); - }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx index 9c8307fc6..3b315ab2c 100644 --- a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -89,12 +89,12 @@ describe('FlagshipReportPage load states', () => { mockFetchMetadata.mockReturnValue(new Promise(() => {})); }); - test('given the report is loading then the run card placeholder holds its place', () => { + test('given the report is loading then a spinner holds the wait', () => { mockUseFlagshipReport.mockReturnValue(reportData({ isLoading: true })); renderPage(EXISTING_REPORT_ID); - // One status, the placeholder's own: no live region around it. + // One status, the spinner's own: no live region around it. expect(screen.getAllByRole('status')).toEqual([ screen.getByRole('status', { name: 'Loading report' }), ]); From 5df69d337e59d3981e6745cc5d625ba9b9d60d29 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 15:59:15 -0400 Subject: [PATCH 25/27] Report: drop the old custom-baseline note tests from ReportView ReportValidation now marks external estimates "Not comparable" for a custom baseline, and its own tests cover that case. The note these two tests looked for is gone. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/ReportView.test.tsx | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx index f4d65a959..6f1002231 100644 --- a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx @@ -123,32 +123,6 @@ describe('ReportView compared with a saved reform', () => { expect(valueHeadings()).toEqual(['Change', 'Before', 'After', 'Start', 'End']); }); - - test('then Validation says published scores compare with current law', async () => { - const user = userEvent.setup(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); - renderWith('Plan B'); - - await user.click(screen.getByRole('tab', { name: 'Validation' })); - - expect( - screen.getByText( - 'Published scores compare with current law. This report compares with Plan B.' - ) - ).toBeInTheDocument(); - fetchSpy.mockRestore(); - }); - - test('given current law then Validation has no such note', async () => { - const user = userEvent.setup(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); - renderWith(); - - await user.click(screen.getByRole('tab', { name: 'Validation' })); - - expect(screen.queryByText(/Published scores compare/)).not.toBeInTheDocument(); - fetchSpy.mockRestore(); - }); }); describe('ReportView budget window', () => { From 682cd523ed9017d49adbbe1701f04a0eb571b007 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:00:33 -0400 Subject: [PATCH 26/27] Report: a bill not in the feed links to its tracker page again BillReportStatus takes an optional tracker URL and shows it beside "All bills" for a bill the feed does not carry. The bill report passes the tracker page for the bill's id. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/BillReportStatus.tsx | 21 ++++++++++++++++--- app/src/pages/flagship/BillReport.page.tsx | 3 ++- .../flagship/report/BillReportStatus.test.tsx | 11 ++++++++++ .../unit/pages/flagship/BillReport.test.tsx | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/components/flagship/report/BillReportStatus.tsx b/app/src/components/flagship/report/BillReportStatus.tsx index a1e4ec000..5b36859d5 100644 --- a/app/src/components/flagship/report/BillReportStatus.tsx +++ b/app/src/components/flagship/report/BillReportStatus.tsx @@ -1,3 +1,4 @@ +import { IconExternalLink } from '@tabler/icons-react'; import ReportSkeleton from '@/components/flagship/report/ReportSkeleton'; import { EmptyState, ErrorState } from '@/components/flagship/states'; import { Button } from '@/components/ui'; @@ -14,11 +15,14 @@ export default function BillReportStatus({ isError, isConfigured, onRetry, + trackerUrl, }: { isLoading: boolean; isError: boolean; isConfigured: boolean; onRetry: () => unknown; + /** The bill's page in the tracker, for a bill the feed does not carry. */ + trackerUrl?: string; }) { const nav = useAppNavigate(); const countryId = useCurrentCountry(); @@ -45,9 +49,20 @@ export default function BillReportStatus({ - All bills - + <> + + {/* The tracker may still have it. */} + {trackerUrl && ( + + )} + } /> ) : ( diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index a8dec33e4..29fe2674c 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -25,7 +25,7 @@ import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; -import { findTrackedBill } from '@/libs/flagship/billTracker'; +import { findTrackedBill, trackerUrlForBillId } from '@/libs/flagship/billTracker'; import { RootState } from '@/store'; interface BillReportPageProps { @@ -283,6 +283,7 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) isError={feed.isError} isConfigured={feed.isConfigured} onRetry={feed.retry} + trackerUrl={billId ? trackerUrlForBillId(billId) : undefined} /> ); } diff --git a/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx index 8ab0b77a2..d6d4fb782 100644 --- a/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx +++ b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx @@ -1,6 +1,7 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import BillReportStatus from '@/components/flagship/report/BillReportStatus'; +import { UNLISTED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; const mockPush = vi.fn(); @@ -45,6 +46,16 @@ describe('BillReportStatus', () => { expect(screen.getByText('This bill is not in the feed.')).toBeInTheDocument(); expect(mockPush).toHaveBeenCalledWith('/us/reforms'); + expect(screen.queryByRole('link', { name: /bill tracker/i })).not.toBeInTheDocument(); + }); + + test('given a bill the feed does not carry and its tracker page then it links there', () => { + render(); + + expect(screen.getByRole('link', { name: /bill tracker/i })).toHaveAttribute( + 'href', + UNLISTED_BILL.trackerUrl + ); }); test('given no feed configured then it says so and offers Build', async () => { diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index 78eceed74..ef9423421 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -196,7 +196,7 @@ describe('BillReportPage', () => { mockFetchTrackerBills.mockResolvedValue([]); renderReport(UNLISTED_BILL.id); - expect(await screen.findByText('This bill is not in the current feed.')).toBeInTheDocument(); + expect(await screen.findByText('This bill is not in the feed.')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /bill tracker/i })).toHaveAttribute( 'href', UNLISTED_BILL.trackerUrl From 74f16c92e21f6d1f23eee8052ba78d33801b6343 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:12:01 -0400 Subject: [PATCH 27/27] Reforms: one place name for a bill, from the tracker's rule billPlace now calls trackerPlace, so the card and the feed's jurisdiction name a bill's place by one rule. The Reforms search reads the place once, not as both billPlace and the jurisdiction. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/billFilters.ts | 7 ++++--- app/src/pages/flagship/Reforms.page.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/libs/flagship/billFilters.ts b/app/src/libs/flagship/billFilters.ts index ddea4f98d..08649891b 100644 --- a/app/src/libs/flagship/billFilters.ts +++ b/app/src/libs/flagship/billFilters.ts @@ -1,5 +1,6 @@ import { STATE_NAMES, type TrackedBill } from '@/api/billFeed'; import { billStage, type BillStage } from './billStage'; +import { trackerPlace } from './billTracker'; /** Federal bills carry no state; every bill with a state is a state bill. */ export type BillLevel = 'federal' | 'state'; @@ -19,9 +20,9 @@ type FilterableBill = Pick; export const billLevel = (bill: Pick): BillLevel => bill.state ? 'state' : 'federal'; -/** Where a bill is from, as its card names it: the state, or "Federal". */ -export const billPlace = (bill: Pick): string => - bill.state ? (STATE_NAMES[bill.state] ?? bill.jurisdiction) : 'Federal'; +/** Where a bill is from, as its card and the tracker name it: the state, or "Federal". */ +export const billPlace = (bill: Pick): string => + trackerPlace(bill.state, STATE_NAMES); function inPlace(bill: FilterableBill, filters: BillFilters): boolean { if (filters.level === 'all') { diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 6891dc482..4667dccfb 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -416,8 +416,8 @@ export default function ReformsPage() { tokens.every((token) => haystack.toLowerCase().includes(token)); const searchedBills = bills.filter((bill) => - // The card's place ("Federal") reads too, not only the tracker's "US". - matches(`${bill.title} ${billPlace(bill)} ${bill.jurisdiction} ${bill.summary}`) + // The place reads once, as the card names it: "Federal", "Utah". + matches(`${bill.title} ${billPlace(bill)} ${bill.summary}`) ); const visibleBills = filterBills(searchedBills, billFilters).sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')