From 58737a8a4511d67c3411b9b7192ae3cb9da110fb Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:01:09 -0400 Subject: [PATCH 1/6] Build: the draft table placeholder lays out like the table on a phone DraftTable exports VALUE_CELL and ACTIONS_CELL. The placeholder rows use them, and its header hides "New value" below 480px as the table does, so the two-column phone grid puts each cell where the real row does. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/reform/DraftTable.tsx | 4 ++-- .../components/flagship/reform/ParametersStatus.tsx | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/components/flagship/reform/DraftTable.tsx b/app/src/components/flagship/reform/DraftTable.tsx index ac0593efb..5e8da8590 100644 --- a/app/src/components/flagship/reform/DraftTable.tsx +++ b/app/src/components/flagship/reform/DraftTable.tsx @@ -37,9 +37,9 @@ export const ROW_GRID = 'tw:grid tw:items-center tw:gap-3 tw:@max-[480px]:gap-y-2 tw:grid-cols-[minmax(0,1fr)_auto] tw:@min-[480px]:grid-cols-[minmax(0,1fr)_150px_64px] tw:@min-[640px]:grid-cols-[minmax(0,1fr)_110px_150px_136px_64px]'; export const WIDE_ONLY = 'tw:hidden tw:@min-[640px]:block'; /** The value cell, under the name across the row on a phone's table. */ -const VALUE_CELL = 'tw:min-w-0 tw:@max-[480px]:col-span-2 tw:@max-[480px]:row-start-2'; +export const VALUE_CELL = 'tw:min-w-0 tw:@max-[480px]:col-span-2 tw:@max-[480px]:row-start-2'; /** The row's own buttons, beside the name on a phone's table, a fingertip each. */ -const ACTIONS_CELL = 'tw:@max-[480px]:col-start-2 tw:@max-[480px]:row-start-1'; +export const ACTIONS_CELL = 'tw:@max-[480px]:col-start-2 tw:@max-[480px]:row-start-1'; const ROW_BUTTON = 'tw:@max-[480px]:size-10 tw:@max-[480px]:items-center tw:@max-[480px]:justify-center'; diff --git a/app/src/components/flagship/reform/ParametersStatus.tsx b/app/src/components/flagship/reform/ParametersStatus.tsx index 9c11610e5..1c6a30f01 100644 --- a/app/src/components/flagship/reform/ParametersStatus.tsx +++ b/app/src/components/flagship/reform/ParametersStatus.tsx @@ -3,7 +3,7 @@ import { ErrorState, SkeletonLine, SkeletonRegion } from '@/components/flagship/ import { colors, spacing, typography } from '@/designTokens'; import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { groupKeyFromPath } from '@/libs/flagship/parameterGroups'; -import { ROW_GRID, WIDE_ONLY } from './DraftTable'; +import { ACTIONS_CELL, ROW_GRID, VALUE_CELL, WIDE_ONLY } from './DraftTable'; /** The search bar and its filter row, as placeholders at their own size. */ export function SearchBarSkeleton() { @@ -65,7 +65,9 @@ function DraftTableSkeleton({ rows }: { rows: number }) { Current law - New value + + New value + Effective @@ -84,11 +86,13 @@ function DraftTableSkeleton({ rows }: { rows: number }) { - +
+ +
- + ))}
Date: Wed, 14 Oct 2026 16:01:37 -0400 Subject: [PATCH 2/6] Build: the baseline picker fits a phone like the population picker On a phone each choice is a fingertip tall, and the search box is 16px, so the browser does not zoom in to type. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/reform/BaselinePicker.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/components/flagship/reform/BaselinePicker.tsx b/app/src/components/flagship/reform/BaselinePicker.tsx index ac00b331b..5fc77f69d 100644 --- a/app/src/components/flagship/reform/BaselinePicker.tsx +++ b/app/src/components/flagship/reform/BaselinePicker.tsx @@ -175,10 +175,17 @@ export default function BaselinePicker({ value, onChange, reforms }: BaselinePic /> - + {reforms.length > 0 && ( Date: Wed, 14 Oct 2026 16:02:20 -0400 Subject: [PATCH 3/6] Report: unreadable saved details use the shared error state ReportDetailsError is now built on ErrorState, with Reload as its retry for details a newer version saved. The heading reads "Could not read this report's saved details", as other v3 errors do. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/ReportDetailsError.tsx | 85 +++++-------------- .../report/ReportDetailsError.test.tsx | 2 +- .../flagship/ReportUnreadableMeta.test.tsx | 2 +- 3 files changed, 24 insertions(+), 65 deletions(-) diff --git a/app/src/components/flagship/report/ReportDetailsError.tsx b/app/src/components/flagship/report/ReportDetailsError.tsx index 9b0cbf321..0cba7e5fb 100644 --- a/app/src/components/flagship/report/ReportDetailsError.tsx +++ b/app/src/components/flagship/report/ReportDetailsError.tsx @@ -1,4 +1,4 @@ -import { IconAlertTriangle } 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'; @@ -23,68 +23,27 @@ export default function ReportDetailsError({ const newer = error.code === 'newer-version'; return ( -
window.location.reload() : undefined} + retryLabel="Reload" + actions={ + <> + + + + } > -
- - - -
-

- This report's saved details can't be read -

-

- {newer - ? 'A newer version of PolicyEngine saved them. Reload to update.' - : 'They are damaged in this browser.'} -

-
-
- -
- {newer && } - - -
-
-
+ ); } diff --git a/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx index cfabeed0b..a99ab4ec8 100644 --- a/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx @@ -15,7 +15,7 @@ describe('ReportDetailsError', () => { render(); expect( - screen.getByRole('heading', { name: "This report's saved details can't be read" }) + screen.getByRole('heading', { name: "Could not read this report's saved details" }) ).toBeInTheDocument(); expect(screen.getByText('They are damaged in this browser.')).toBeInTheDocument(); expect(screen.getByText('provisions must be an array')).toBeInTheDocument(); diff --git a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx index de78cfa57..c687dc8b3 100644 --- a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx @@ -12,7 +12,7 @@ import { // schema, from real localStorage (Report.test.tsx stubs the read). const REPORT_ID = 'r1'; -const UNREADABLE_HEADING = "This report's saved details can't be read"; +const UNREADABLE_HEADING = "Could not read this report's saved details"; const WAITING_TEXT = 'Calculating the nationwide results'; vi.mock('@/hooks/useFlagshipReport', () => ({ From 5ba05bba3aeb23f0016215ed1bdfef68335cbfcc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:02:57 -0400 Subject: [PATCH 4/6] Report: a failed household uses the shared error state The household section's failure card is now a compact ErrorState, with the API message under the title and "Try again" running the household again. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdImpactSection.tsx | 55 +++++-------------- .../report/HouseholdImpactSection.test.tsx | 2 +- 2 files changed, 14 insertions(+), 43 deletions(-) diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index d715c8741..07a1297e2 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { IconAlertTriangle } from '@tabler/icons-react'; import HouseholdForm from '@/components/flagship/report/household/HouseholdForm'; import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; import RunProgress from '@/components/flagship/report/RunProgress'; -import { Button, Stack, Text } from '@/components/ui'; +import { ErrorState } from '@/components/flagship/states'; +import { Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useFlagshipHousehold } from '@/hooks/useFlagshipHousehold'; import { @@ -81,46 +81,17 @@ export default function HouseholdImpactSection({ {running && } {failed && ( -
- - - - This household could not be calculated. - - {run.error?.message && ( - - {run.error.message} - - )} - - -
+ {run.error.message} + ) + } + onRetry={run.refetch} + /> )} {result && requested && ( diff --git a/app/src/tests/unit/components/flagship/report/HouseholdImpactSection.test.tsx b/app/src/tests/unit/components/flagship/report/HouseholdImpactSection.test.tsx index c61218901..6a67627b4 100644 --- a/app/src/tests/unit/components/flagship/report/HouseholdImpactSection.test.tsx +++ b/app/src/tests/unit/components/flagship/report/HouseholdImpactSection.test.tsx @@ -236,7 +236,7 @@ describe('HouseholdImpactSection', () => { await user.click(screen.getByRole('button', { name: 'Calculate' })); const alert = await screen.findByRole('alert'); - expect(alert).toHaveTextContent('This household could not be calculated.'); + expect(alert).toHaveTextContent('This household could not be calculated'); expect(alert).toHaveTextContent('Household calculation failed'); await user.click(within(alert).getByRole('button', { name: 'Try again' })); From ec13088f6bc38e0c42d5acedb9b34b2270c2247a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:04:18 -0400 Subject: [PATCH 5/6] Report: validation cards wait with placeholder lines, not a spinner ValidationLoading is now a SkeletonRegion with three placeholder lines, named for what is loading, as the other v3 waits are. Its callers pass the name as a label. Co-Authored-By: Claude Opus 5.5 --- .../flagship/CalibrationMatches.tsx | 2 +- .../components/flagship/ValidationDisplay.tsx | 22 +++++++++++++------ .../components/flagship/ValidationPanel.tsx | 2 +- .../flagship/report/ReportValidation.tsx | 2 +- .../flagship/CalibrationMatches.test.tsx | 4 +++- .../flagship/ValidationPanel.test.tsx | 4 +++- .../flagship/report/ReportValidation.test.tsx | 6 ++--- 7 files changed, 27 insertions(+), 15 deletions(-) diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index acb1ffe43..124a026f9 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -211,7 +211,7 @@ export function CalibrationMatchSection({ return card(Calibration checks are temporarily unavailable.); } if (matches === undefined) { - return card(Checking calibration targets…); + return card(); } if (matches.matches.length === 0) { const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`; diff --git a/app/src/components/flagship/ValidationDisplay.tsx b/app/src/components/flagship/ValidationDisplay.tsx index e05549c32..5c11ba7d5 100644 --- a/app/src/components/flagship/ValidationDisplay.tsx +++ b/app/src/components/flagship/ValidationDisplay.tsx @@ -1,6 +1,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; -import { Badge, Spinner, Stack, Text } from '@/components/ui'; +import { SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; +import { Badge, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; /** @@ -158,13 +159,20 @@ export function NotComparable({ children }: { children: ReactNode }) { ); } -/** A card's check is still loading; the line, not the spinner, is what gets announced. */ -export function ValidationLoading({ children }: { children: ReactNode }) { +/** + * A card's check is still loading: placeholder lines where its rows will + * be, as the rest of app v3 waits, named for what is coming. + */ +export function ValidationLoading({ label }: { label: string }) { return ( - - + + + + + ); } diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index 8db215dce..2866d1771 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -336,7 +336,7 @@ export function BaselineComparisons({ } else if (entries.length > 0) { content = ; } else if (trackRecord === undefined || scorecardRows === undefined) { - content = Loading baseline comparisons…; + content = ; } else if (trackRecord === null) { content = Baseline comparisons are temporarily unavailable.; } else if (trackRecord.reachedCount > 0) { diff --git a/app/src/components/flagship/report/ReportValidation.tsx b/app/src/components/flagship/report/ReportValidation.tsx index 9015c3472..3d5417096 100644 --- a/app/src/components/flagship/report/ReportValidation.tsx +++ b/app/src/components/flagship/report/ReportValidation.tsx @@ -69,7 +69,7 @@ function ExternalEstimates({ if (!estimates && reforms.length === 0) { fallback = scorecard === undefined ? ( - Loading external estimates… + ) : scorecard === null ? ( External estimates are temporarily unavailable. ) : ( diff --git a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx index 8dcca7f11..2af77e229 100644 --- a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx +++ b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx @@ -24,7 +24,9 @@ describe('CalibrationMatchSection', () => { test('given matches are loading then the loading state renders', () => { render(); - expect(screen.getByRole('status')).toHaveTextContent('Checking calibration targets…'); + expect( + screen.getByRole('status', { name: 'Checking calibration targets' }) + ).toBeInTheDocument(); }); test('given reached variables but none calibrated then the gap is stated, not hidden', () => { diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index d593c1618..046a9eff2 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -110,7 +110,9 @@ describe('BaselineComparisons', () => { test('given matches are loading then the loading state renders', () => { render(); - expect(screen.getByText(/loading baseline comparisons/i)).toBeInTheDocument(); + expect( + screen.getByRole('status', { name: 'Loading baseline comparisons' }) + ).toBeInTheDocument(); }); test('given the reform reaches nothing then a plain line says no comparison covers it', () => { 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 0269d3603..586cd20ea 100644 --- a/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx @@ -128,9 +128,9 @@ describe('ReportValidation', () => { mockUseScorecardComparisons.mockReturnValue(undefined); renderValidation(); - expect(within(card('External estimates')).getByRole('status')).toHaveTextContent( - 'Loading external estimates…' - ); + expect( + within(card('External estimates')).getByRole('status', { name: 'Loading external estimates' }) + ).toBeInTheDocument(); }); test('given the scorecard is unavailable then the estimates card says so, not that none exists', () => { From 88cc9568fad24ed694a100d7b1a4d494e3faeff7 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 16:10:19 -0400 Subject: [PATCH 6/6] Report: a baseline policy that fails to load is unknown, not current law useFlagshipReport names the policies that failed to load. When a report has no record of its baseline (a shared link) and its baseline policy, not current law, failed, the report reads as compared with "a baseline policy that could not be loaded": the scorecard is not compared, the overview offers to load the policy again, and Recompute is off with the reason, since it would compare with current law. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportAdjustPanel.tsx | 16 ++++- .../components/flagship/report/ReportView.tsx | 7 ++ app/src/hooks/useFlagshipReport.ts | 11 +++ app/src/libs/flagship/reportBaseline.ts | 22 ++++++ app/src/pages/flagship/Report.page.tsx | 68 +++++++++++++++---- .../flagship/ReportAdjustPanel.test.tsx | 23 ++++++- .../unit/hooks/useFlagshipReport.test.tsx | 2 + .../unit/libs/flagship/reportBaseline.test.ts | 32 +++++++++ .../tests/unit/pages/flagship/Report.test.tsx | 33 +++++++++ .../pages/flagship/ReportLoadStates.test.tsx | 1 + .../flagship/ReportUnreadableMeta.test.tsx | 1 + 11 files changed, 199 insertions(+), 17 deletions(-) diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index cccdfb0e2..7fa1ac21f 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -35,6 +35,11 @@ interface ReportAdjustPanelProps { year?: string; /** What the report is compared with, so a recompute is too; current law when unset. */ baseline?: RunReportBaseline; + /** + * The report's baseline policy could not be loaded. A recompute would + * compare with current law instead, so it is off. + */ + baselineUnknown?: boolean; } /** True when the reform's parameter set is exactly the given provisions. */ @@ -66,6 +71,7 @@ export default function ReportAdjustPanel({ region, year, baseline, + baselineUnknown = false, }: ReportAdjustPanelProps) { const runReport = useRunFlagshipReport(); const nav = useAppNavigate(); @@ -351,7 +357,15 @@ export default function ReportAdjustPanel({ {reconcileError || runReport.error} )} - diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index efe974e11..6c9f72a98 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -84,6 +84,11 @@ export interface ReportViewProps { provisionsLoading?: boolean; /** What the results are compared with, by name; current law when unset. */ customBaselineLabel?: string | null; + /** + * The baseline policy could not be loaded: the adjust panel cannot + * recompute against it. + */ + baselineUnknown?: boolean; /** What the adjust panel's runs compare with; current law when unset. */ baseline?: RunReportBaseline; year: string; @@ -133,6 +138,7 @@ export default function ReportView({ priorLawBaseline = false, provisionsLoading = false, customBaselineLabel, + baselineUnknown = false, baseline, year, region, @@ -429,6 +435,7 @@ export default function ReportView({ region={region} year={year} baseline={baseline} + baselineUnknown={baselineUnknown} />
diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index febbb7c73..9254e883c 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -37,6 +37,11 @@ export interface FlagshipReportData { error: Error | null; /** The report id resolves to no report (a bad or deleted link). */ notFound: boolean; + /** + * The policies that failed to load, with nothing to show. A missing + * baseline policy leaves what the report compares with unknown. + */ + failedPolicyIds: string[]; /** Asks again for whatever failed; settles when the answers are in. */ retry: () => Promise; } @@ -92,6 +97,10 @@ export function useFlagshipReport(id: string): FlagshipReportData { structuralSharing: false, }); const policies = policyResults.queries.map((q) => q.data).filter((p): p is Policy => !!p); + const failedPolicyIds = policyIds.filter((_, index) => { + const query = policyResults.queries[index]; + return !!query?.error && !query.data; + }); if (!direct) { return { @@ -102,6 +111,7 @@ export function useFlagshipReport(id: string): FlagshipReportData { // A legacy link that resolves to nothing reads as unresolvable. error: null, notFound: false, + failedPolicyIds: [], retry: async () => undefined, }; } @@ -120,6 +130,7 @@ export function useFlagshipReport(id: string): FlagshipReportData { isLoading: reportLoading || simulationResults.isLoading || policyResults.isLoading, error, notFound: !!unloadedReportError && isNotFoundError(unloadedReportError), + failedPolicyIds, retry: () => Promise.all([ reportError ? refetchReport() : null, diff --git a/app/src/libs/flagship/reportBaseline.ts b/app/src/libs/flagship/reportBaseline.ts index 8650a10b8..fdef9fd7a 100644 --- a/app/src/libs/flagship/reportBaseline.ts +++ b/app/src/libs/flagship/reportBaseline.ts @@ -23,6 +23,28 @@ export function reportBaselineLabel( : null; } +/** What a report compares with when its baseline policy could not be loaded. */ +export const UNKNOWN_BASELINE_LABEL = 'a baseline policy that could not be loaded'; + +/** + * Whether what the report compares with is unknown: no record of the + * run's baseline (a shared link), and a baseline policy, not current + * law, that failed to load. Read as current law, the report would claim + * a comparison that it cannot show. + */ +export function reportBaselineUnknown( + meta: Pick | null | undefined, + baselinePolicyId: string | undefined, + { currentLawId, failedPolicyIds }: { currentLawId: number; failedPolicyIds: string[] } +): boolean { + return ( + !meta?.baseline && + !!baselinePolicyId && + baselinePolicyId !== String(currentLawId) && + failedPolicyIds.includes(baselinePolicyId) + ); +} + /** * What a new run from this report compares with: the same baseline * policy when it is not current law, so an adjusted report keeps its diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index c3d826b69..024c281a0 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -22,13 +22,33 @@ import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; -import { reportBaselineLabel, reportRunBaseline } from '@/libs/flagship/reportBaseline'; +import { + reportBaselineLabel, + reportBaselineUnknown, + reportRunBaseline, + UNKNOWN_BASELINE_LABEL, +} from '@/libs/flagship/reportBaseline'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; import { readReportMeta } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; import type { CalcStartConfig } from '@/types/calculation'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; +/** A failed load at the top of the overview, in a dashed box. */ +function LeadError({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + interface FlagshipReportPageProps { /** Passed by the Next.js route bridge; react-router falls back to params. */ userReportId?: string; @@ -51,12 +71,14 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep isLoading, error: loadError, notFound, + failedPolicyIds, 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); const economyRegions = useSelector((state: RootState) => state.metadata.economyOptions.region); + const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); // Provenance: the local stash from the run, else rebuilt from the reform // policy so a shared link validates like the original. const reformPolicy = policies.find((policy) => policy.id === simulations?.[1]?.policyId); @@ -120,9 +142,18 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const reformPolicyId = simulations?.[1]?.policyId; const baselinePolicyId = simulations?.[0]?.policyId; const baselinePolicy = policies.find((policy) => policy.id === baselinePolicyId); - const customBaselineLabel = reportBaselineLabel(meta, baselinePolicy); - // Adjusted, the report keeps what it was compared with. - const runBaseline = reportRunBaseline(meta, baselinePolicyId, customBaselineLabel); + // A baseline policy that failed to load is unknown, not current law. + const baselineUnknown = reportBaselineUnknown(meta, baselinePolicyId, { + currentLawId, + failedPolicyIds, + }); + const customBaselineLabel = baselineUnknown + ? UNKNOWN_BASELINE_LABEL + : reportBaselineLabel(meta, baselinePolicy); + // Adjusted, the report keeps what it was compared with; an unknown one gives it nothing to keep. + const runBaseline = baselineUnknown + ? undefined + : reportRunBaseline(meta, baselinePolicyId, customBaselineLabel); const region = simulations?.[0]?.populationId; // A report on part of the country names it: "California". const regionLabel = @@ -269,19 +300,26 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep budgetWindow={meta?.budgetWindow} provisions={meta?.provisions ?? []} overviewLead={ - !meta && metadata.status === 'error' ? ( -
- -
- ) : undefined + <> + {!meta && metadata.status === 'error' && ( + + + + )} + {baselineUnknown && ( + + + + )} + } customBaselineLabel={customBaselineLabel} + baselineUnknown={baselineUnknown} baseline={runBaseline} year={report?.year ?? ''} region={region} diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index 5ea7d326b..4cb0571a6 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -63,7 +63,13 @@ async function renderPanel({ region, year, baseline, -}: { region?: string; year?: string; baseline?: RunReportBaseline } = {}) { + baselineUnknown, +}: { + region?: string; + year?: string; + baseline?: RunReportBaseline; + baselineUnknown?: boolean; +} = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const result = render( @@ -74,6 +80,7 @@ async function renderPanel({ region={region} year={year} baseline={baseline} + baselineUnknown={baselineUnknown} /> ); @@ -365,6 +372,20 @@ describe('ReportAdjustPanel', () => { expect(screen.getByLabelText(/new value for maximum credit per child/i)).toBeInTheDocument(); }); + test('given the baseline policy could not be loaded then recompute is off and says why', async () => { + const user = userEvent.setup(); + await renderPanel({ baselineUnknown: true }); + + await user.click(screen.getByRole('button', { name: /remove maximum allotment/i })); + + expect(screen.getByRole('button', { name: /recompute/i })).toBeDisabled(); + expect( + screen.getByText( + 'The baseline policy could not be loaded, so a recompute cannot compare with it.' + ) + ).toBeInTheDocument(); + }); + test('given collapse then the panel shrinks to the adjust tab and reopens', async () => { const user = userEvent.setup(); await renderPanel(); diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx index 34a051c79..200b272c2 100644 --- a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -165,6 +165,8 @@ describe('useFlagshipReport', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.simulations).toHaveLength(2); expect(result.current.error).toBeNull(); + // Each failed policy is named, so a missing baseline reads as unknown. + await waitFor(() => expect(result.current.failedPolicyIds).toEqual(['2', '98557'])); }); test('given a failed simulation then retry asks for it again and the error clears', async () => { diff --git a/app/src/tests/unit/libs/flagship/reportBaseline.test.ts b/app/src/tests/unit/libs/flagship/reportBaseline.test.ts index 7af80d3a2..ddbf80d1b 100644 --- a/app/src/tests/unit/libs/flagship/reportBaseline.test.ts +++ b/app/src/tests/unit/libs/flagship/reportBaseline.test.ts @@ -3,6 +3,7 @@ import { baselineProvisions, customBaseline, reportBaselineLabel, + reportBaselineUnknown, reportRunBaseline, } from '@/libs/flagship/reportBaseline'; @@ -39,6 +40,37 @@ describe('reportBaselineLabel', () => { }); }); +describe('reportBaselineUnknown', () => { + const CURRENT_LAW_ID = 2; + + test('given a shared link whose baseline policy failed then the baseline is unknown', () => { + expect( + reportBaselineUnknown(null, '88', { currentLawId: CURRENT_LAW_ID, failedPolicyIds: ['88'] }) + ).toBe(true); + }); + + test('given the baseline policy loaded then the baseline is known', () => { + expect( + reportBaselineUnknown(null, '88', { currentLawId: CURRENT_LAW_ID, failedPolicyIds: ['99'] }) + ).toBe(false); + }); + + test('given current law failed to load then it is still current law', () => { + expect( + reportBaselineUnknown(null, '2', { currentLawId: CURRENT_LAW_ID, failedPolicyIds: ['2'] }) + ).toBe(false); + }); + + test('given the run recorded its baseline then a failed policy leaves it known', () => { + expect( + reportBaselineUnknown(PLAN_B_META, '88', { + currentLawId: CURRENT_LAW_ID, + failedPolicyIds: ['88'], + }) + ).toBe(false); + }); +}); + describe('reportRunBaseline', () => { test('given a report compared with a saved reform then a re-run reuses its baseline policy', () => { expect(reportRunBaseline(PLAN_B_META, '88', 'Plan B')).toEqual({ diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 5c5401d20..90625cbf2 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -10,6 +10,9 @@ import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutput const mockCalcStatus = vi.fn(); // The report's policies; a baseline policy with parameters is a custom baseline. const mockPolicies = vi.hoisted(() => ({ current: [] as unknown[] })); +// The policies that failed to load, by id. +const mockFailedPolicyIds = vi.hoisted(() => ({ current: [] as string[] })); +const mockRetry = vi.fn(); const META: FlagshipReportMeta = { title: 'Test Reform', @@ -38,6 +41,8 @@ vi.mock('@/hooks/useFlagshipReport', () => ({ ], policies: mockPolicies.current, isLoading: false, + failedPolicyIds: mockFailedPolicyIds.current, + retry: mockRetry, }), })); @@ -91,6 +96,8 @@ describe('FlagshipReportPage', () => { mockCalcStatus.mockReset(); mockMeta = META; mockPolicies.current = []; + mockFailedPolicyIds.current = []; + mockRetry.mockReset(); mockStartCalculationOnLoad.mockReset(); }); @@ -218,6 +225,32 @@ describe('FlagshipReportPage', () => { }); }); + test('given the baseline policy failed to load then the report does not read as current law', async () => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockFailedPolicyIds.current = ['2']; + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + + renderPage(); + + expect( + screen.getByText('Compared with a baseline policy that could not be loaded') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(mockRetry).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('tab', { name: 'Validation' })); + expect(within(screen.getByRole('tabpanel')).getAllByText('Not comparable')).toHaveLength(2); + }); + test('given a failed run then Build gets the reform back with the same comparison', async () => { clearDraftReform(); mockMeta = PLAN_B_META; diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx index 3b315ab2c..995848aee 100644 --- a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -65,6 +65,7 @@ function reportData(overrides: Partial = {}): FlagshipReport isLoading: false, error: null, notFound: false, + failedPolicyIds: [], retry: mockRetry, ...overrides, }; diff --git a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx index c687dc8b3..74f2b0a85 100644 --- a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/hooks/useFlagshipReport', () => ({ ], policies: [], isLoading: false, + failedPolicyIds: [], }), }));