From 9ae810727e421e01bce77bf3df40c49133843f0e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:11:54 -0400 Subject: [PATCH 01/40] Bills: tracker pages read in a stable row order Each paged read of a tracker table now sends an order on the table's key. Without ORDER BY, Postgres gave the two 1,000-row pages of processed_bills in a different order on each load, so some rows came back twice and others were never read. Those bills lost their stage badge, their bill text link and their place in the stage filter. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 19 ++++++- app/src/tests/fixtures/api/billFeedMocks.ts | 58 +++++++++++++++++++++ app/src/tests/unit/api/billFeed.test.ts | 34 ++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..d66c70fc7 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -186,8 +186,23 @@ function totalRows(response: Response): number | undefined { return total && total !== '*' ? Number(total) : undefined; } +/** + * The columns each tracker table's rows sort by: its key. Postgres keeps + * no row order without ORDER BY, so pages read by offset with no order + * can repeat some rows and skip others, and a different set each load. + */ +const TRACKER_TABLE_ORDER = { + research: 'id.asc', + reform_impacts: 'id.asc', + validation_metadata: 'id.asc', + // bill_id is LegiScan's id; the state and bill number break any tie. + processed_bills: 'bill_id.asc,state.asc,bill_number.asc', +} as const; + +type TrackerTable = keyof typeof TRACKER_TABLE_ORDER; + async function trackerSelect( - table: string, + table: TrackerTable, select: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string @@ -199,7 +214,7 @@ async function trackerSelect( // Read page by page: one request stops at the cap and drops the rest. const rows: any[] = []; for (;;) { - const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&order=${TRACKER_TABLE_ORDER[table]}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 1c4dc74bb..86e9716c9 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -10,6 +10,64 @@ export const mockTrackerFetch = (rows: TrackerTableRows) => return { ok: true, json: async () => rows[table] ?? [] }; }); +/** The most rows one tracker request returns. */ +const TRACKER_PAGE_SIZE = 1000; + +/** + * A fetch that serves each tracker table 1,000 rows at a time, as + * PostgREST does. With no `order` parameter, each request sees the rows + * in another order (Postgres keeps no row order without ORDER BY), so + * pages overlap and skip rows. With `order=.asc,...`, the rows + * sort by those columns, and the pages fit together. + */ +export const mockPagedTrackerFetch = (rows: TrackerTableRows) => { + const requests: Record = {}; + return vi.fn(async (url: string) => { + const parsed = new URL(String(url)); + const table = parsed.pathname.split('/').pop() ?? ''; + const offset = Number(parsed.searchParams.get('offset') ?? 0); + const order = parsed.searchParams.get('order'); + const all = rows[table] ?? []; + requests[table] = (requests[table] ?? 0) + 1; + const columns = order?.split(',').map((term) => term.split('.')[0]) ?? []; + const served = + columns.length > 0 + ? [...all].sort((a, b) => { + for (const column of columns) { + if (a[column] !== b[column]) { + return a[column] < b[column] ? -1 : 1; + } + } + return 0; + }) + : // Unordered: every other request to a table reads it back to front. + requests[table] % 2 === 0 + ? [...all].reverse() + : all; + const page = served.slice(offset, offset + TRACKER_PAGE_SIZE); + return { + ok: true, + headers: new Headers({ + 'content-range': `${offset}-${offset + page.length - 1}/${all.length}`, + }), + json: async () => page, + }; + }); +}; + +/** + * The bill-status table: 1,418 rows, more than one request returns. The + * bill's own row is the last one. + */ +export const LARGE_STATUS_TABLE_ROWS: TrackerTableRows = { + research: [{ id: 'ut-hb210', state: 'UT', type: 'bill', status: 'published', title: 'HB 210' }], + processed_bills: Array.from({ length: 1418 }, (_, index) => + index === 1417 + ? { bill_id: index, state: 'UT', bill_number: 'HB210', status: 'Signed' } + : { bill_id: index, state: 'UT', bill_number: `HB${index + 5000}`, status: 'Introduced' } + ), +}; + /** Bills that read by the tracker's place names, titles and pages. */ export const TRACKER_NAMING_ROWS: TrackerTableRows = { research: [ diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index fd0c4da9b..d1454187e 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -9,6 +9,8 @@ import { } from '@/api/billFeed'; import { CURRENT_YEAR } from '@/constants'; import { + LARGE_STATUS_TABLE_ROWS, + mockPagedTrackerFetch, mockTrackerFetch, NET_COST_DRIFT_ROWS, NET_COST_ONLY_ROWS, @@ -300,6 +302,38 @@ describe('tracker reads', () => { '1000', ]); }); + + test('given the server keeps no row order then the pages still read every row once', async () => { + // Given + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockPagedTrackerFetch(LARGE_STATUS_TABLE_ROWS)); + + // When + const bills = await fetchTrackerBills(); + + // Then: the bill's status row is the last one, on the second page. + expect(bills?.[0].status).toBe('Signed'); + }); + + test('given a tracker read then every request names a stable row order', async () => { + // Given + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const fetchMock = mockPagedTrackerFetch(LARGE_STATUS_TABLE_ROWS); + vi.stubGlobal('fetch', fetchMock); + + // When + await fetchTrackerBills(); + await fetchTrackerBillDistricts('ut-hb210'); + + // Then + const orders = fetchMock.mock.calls.map(([url]) => + new URL(String(url)).searchParams.get('order') + ); + expect(orders.length).toBeGreaterThan(0); + expect(orders.every((order) => typeof order === 'string' && order.length > 0)).toBe(true); + }); }); describe('fetchTrackerBills', () => { From 433382ebaa5fe9a385379f73412c734a4f6af5b1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:12:34 -0400 Subject: [PATCH 02/40] Report: a shared link describes the policy changes in the report's year The provisions rebuilt from the reform policy took today's current law, the 2026 value (or the first interval), and no dates, so a shared 2027 report read 'From Jan 1, 2026'. They now carry the policy's dated values and pass through provisionsForYear for the report year, as the run's own stash does. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/reportProvenance.ts | 39 ++-- app/src/pages/flagship/Report.page.tsx | 5 +- .../libs/flagship/reportProvenance.test.ts | 103 +++++++++- .../flagship/ReportSharedProvenance.test.tsx | 183 ++++++++++++++++++ 4 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 app/src/tests/unit/pages/flagship/ReportSharedProvenance.test.tsx diff --git a/app/src/libs/flagship/reportProvenance.ts b/app/src/libs/flagship/reportProvenance.ts index a9d498bbd..ded2a2101 100644 --- a/app/src/libs/flagship/reportProvenance.ts +++ b/app/src/libs/flagship/reportProvenance.ts @@ -9,30 +9,39 @@ * changes, so they are rebuilt from it here. */ import { CURRENT_YEAR } from '@/constants'; +import { provisionsForYear } from '@/libs/draftReform'; import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; import type { Policy } from '@/types/ingredients/Policy'; -import type { Parameter } from '@/types/subIngredients/parameter'; +import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; import { getCurrentValue } from '@/utils/parameterValues'; -/** The value a parameter's intervals set for `year`; the first interval when none covers it. */ -export function valueForYear(parameter: Parameter, year: string = CURRENT_YEAR): unknown { - const covering = parameter.values.find( - (interval) => - String(interval.startDate).slice(0, 4) <= year && - String(interval.endDate ?? '9999').slice(0, 4) >= year - ); - return (covering ?? parameter.values[0])?.value; -} - -/** Rebuilds report provisions from a policy's parameter changes. */ +/** + * Rebuilds report provisions from a policy's parameter changes, as they + * stand in the report's year: current law then, the reform's value then, + * and the reform's own dates. The browser that ran the report stashes + * the same through `provisionsForYear`. + */ export function provisionsFromPolicy( policy: Policy | null | undefined, parameters: Record | null | undefined, year: string = CURRENT_YEAR ): RunReportProvision[] { - return (policy?.parameters ?? []).map((parameter) => { + const reportYear = Number(year) || Number(CURRENT_YEAR); + /** Current law on 1 January of a year; today's value when its dates cannot tell. */ + const currentLawAt = (path: string, at: number) => { + const values = parameters?.[path]?.values; + return values + ? (new ValueIntervalCollection(values).getValueAtDate(`${at}-01-01`) ?? + getCurrentValue(values)) + : undefined; + }; + const provisions = (policy?.parameters ?? []).map((parameter) => { const metadata = parameters?.[parameter.name]; + // The dates in order, as the report's dated reasons read them. + const intervals = [...(parameter.values ?? [])].sort((a, b) => + a.startDate.localeCompare(b.startDate) + ); return { path: parameter.name, breadcrumb: metadata @@ -40,9 +49,11 @@ export function provisionsFromPolicy( : parameter.name, unit: metadata?.unit ?? null, baselineValue: getCurrentValue(metadata?.values), - value: valueForYear(parameter, year), + value: intervals[0]?.value, + ...(intervals.length > 0 && { intervals }), }; }); + return provisionsForYear(provisions, reportYear, currentLawAt); } /** diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 024c281a0..d1f819fa7 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -86,7 +86,10 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // baseline, so they count as still loading until it arrives. const meta = readReportMeta(userReportId) ?? - (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); + (parameters + ? // Described in the year the report simulates, as the run's own stash is. + provenanceFromPolicy(reformPolicy, parameters, report?.label, report?.year) + : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); // Stored provenance that can't be read: readReportMeta answers none for it. diff --git a/app/src/tests/unit/libs/flagship/reportProvenance.test.ts b/app/src/tests/unit/libs/flagship/reportProvenance.test.ts index 8b811c0ba..f68cb92f2 100644 --- a/app/src/tests/unit/libs/flagship/reportProvenance.test.ts +++ b/app/src/tests/unit/libs/flagship/reportProvenance.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from 'vitest'; import { isApiReportId } from '@/hooks/useFlagshipReport'; -import { - provenanceFromPolicy, - provisionsFromPolicy, - valueForYear, -} from '@/libs/flagship/reportProvenance'; +import { provenanceFromPolicy, provisionsFromPolicy } from '@/libs/flagship/reportProvenance'; import type { Policy } from '@/types/ingredients/Policy'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; @@ -44,14 +40,30 @@ describe('isApiReportId', () => { }); }); -describe('valueForYear', () => { +describe('the reform value in the report year', () => { test('given dated intervals then the one covering the year wins', () => { - expect(valueForYear(reformPolicy.parameters![0], '2026')).toBe(2500); - expect(valueForYear(reformPolicy.parameters![0], '2025')).toBe(2200); + expect(provisionsFromPolicy(reformPolicy, metadataParameters, '2026')[0].value).toBe(2500); + expect(provisionsFromPolicy(reformPolicy, metadataParameters, '2025')[0].value).toBe(2200); }); - test('given no covering interval then the first stands in', () => { - expect(valueForYear(reformPolicy.parameters![0], '2010')).toBe(2200); + test('given no covering interval then the value is current law, not the first interval', () => { + // Given: current law is $2,000 in 2025, before the reform's first value. + const [provision] = provisionsFromPolicy( + { + ...reformPolicy, + parameters: [ + { + name: CTC_BASE_PATH, + values: [{ startDate: '2027-01-01', endDate: '2100-12-31', value: 2500 }], + }, + ], + }, + metadataParameters, + '2025' + ); + + // Then + expect(provision).toMatchObject({ baselineValue: 2000, value: 2000 }); }); }); @@ -77,6 +89,77 @@ describe('provisionsFromPolicy', () => { }); }); +describe('provisionsFromPolicy for the report year', () => { + const MD_CTC_PATH = 'gov.states.md.tax.income.credits.ctc.amount'; + // A reform that starts in 2027 and rises over time, like policy 98731. + const datedReform: Policy = { + id: '98731', + countryId: 'us', + label: 'MD CTC Increase', + parameters: [ + { + name: MD_CTC_PATH, + values: [ + { startDate: '2030-01-01', endDate: '2100-12-31', value: 643 }, + { startDate: '2027-01-01', endDate: '2027-12-31', value: 600 }, + { startDate: '2028-01-01', endDate: '2029-12-31', value: 614 }, + ], + }, + ], + }; + const mdMetadata = { + [MD_CTC_PATH]: { + parameter: MD_CTC_PATH, + label: 'Amount', + unit: 'currency-USD', + values: { '2023-01-01': 500, '2030-01-01': 550 }, + }, + }; + + test('given a 2031 report then current law and the reform read as they stand in 2031', () => { + // When + const [provision] = provisionsFromPolicy(datedReform, mdMetadata, '2031'); + + // Then + expect({ before: provision.baselineValue, after: provision.value }).toEqual({ + before: 550, + after: 643, + }); + }); + + test('given a year before the reform starts then the value is current law then', () => { + // When + const [provision] = provisionsFromPolicy(datedReform, mdMetadata, '2026'); + + // Then + expect({ before: provision.baselineValue, after: provision.value }).toEqual({ + before: 500, + after: 500, + }); + }); + + test('given dated values then the provision carries them in date order', () => { + // When + const [provision] = provisionsFromPolicy(datedReform, mdMetadata, '2027'); + + // Then + expect(provision.intervals?.map((interval) => interval.startDate)).toEqual([ + '2027-01-01', + '2028-01-01', + '2030-01-01', + ]); + expect(provision.value).toBe(600); + }); + + test('given a report year then the provenance describes that year', () => { + // When + const meta = provenanceFromPolicy(datedReform, mdMetadata, 'MD CTC', '2028'); + + // Then + expect(meta?.provisions[0]).toMatchObject({ baselineValue: 500, value: 614 }); + }); +}); + describe('provenanceFromPolicy', () => { test('given a policy then the report label leads and the policy label backs it', () => { expect(provenanceFromPolicy(reformPolicy, metadataParameters, 'Shared CTC run')?.title).toBe( diff --git a/app/src/tests/unit/pages/flagship/ReportSharedProvenance.test.tsx b/app/src/tests/unit/pages/flagship/ReportSharedProvenance.test.tsx new file mode 100644 index 000000000..9eb5a45e6 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportSharedProvenance.test.tsx @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, within } 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, fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + BASELINE_SIMULATION_ID, + EXISTING_REPORT, + EXISTING_REPORT_ID, + REFORM_SIMULATION_ID, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; +import type { Policy } from '@/types/ingredients/Policy'; +import type { Simulation } from '@/types/ingredients/Simulation'; + +const mockUseFlagshipReport = vi.fn(); +const mockCalcStatus = vi.fn(); + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => mockUseFlagshipReport(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: () => new Promise(() => {}), +})); + +vi.mock('@/api/budgetWindow', async (importOriginal) => ({ + ...(await importOriginal()), + fetchBudgetWindow: async () => ({ status: 'computing', result: null, progress: 10 }), +})); + +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, +})); + +const MD_CTC_PATH = 'gov.states.md.tax.income.credits.ctc.amount'; +const REFORM_POLICY_ID = '98731'; + +/** Current law for the Maryland CTC: $500 a child, rising to $550 in 2030. */ +function seedMarylandMetadata() { + const meta = { arg: 'us', requestId: 'shared-provenance-test', requestStatus: 'pending' }; + store.dispatch({ type: fetchMetadataThunk.pending.type, meta }); + store.dispatch({ + type: fetchMetadataThunk.fulfilled.type, + meta: { ...meta, requestStatus: 'fulfilled' }, + payload: { + country: 'us', + data: { + result: { + parameters: { + [MD_CTC_PATH]: { + parameter: MD_CTC_PATH, + type: 'parameter', + label: 'Maryland Child Tax Credit amount per eligible child', + unit: 'currency-USD', + period: 'year', + values: { '2023-01-01': 500, '2030-01-01': 550 }, + }, + }, + variables: {}, + entities: {}, + variableModules: {}, + economy_options: { region: [], time_period: [], datasets: [] }, + current_law_id: 2, + basicInputs: [], + modelled_policies: { core: {}, filtered: {} }, + version: '1.0.0', + }, + }, + }, + }); +} + +/** A reform that starts in 2027 and steps up over time, as policy 98731 does. */ +const DATED_REFORM: Policy = { + id: REFORM_POLICY_ID, + countryId: 'us', + label: 'MD CTC Increase', + parameters: [ + { + name: MD_CTC_PATH, + values: [ + { startDate: '2027-01-01', endDate: '2027-12-31', value: 600 }, + { startDate: '2028-01-01', endDate: '2029-12-31', value: 614 }, + { startDate: '2030-01-01', endDate: '2100-12-31', value: 643 }, + ], + }, + ], +}; + +const simulation = (id: string, policyId: string): Simulation => ({ + id, + countryId: 'us', + apiVersion: 'v1', + policyId, + populationId: 'state/md', + populationType: 'geography', + label: null, + isCreated: true, + status: 'complete', +}); + +function sharedReport(year: string): FlagshipReportData { + return { + report: { ...EXISTING_REPORT, label: 'MD CTC Increase', year }, + simulations: [ + simulation(BASELINE_SIMULATION_ID, '2'), + simulation(REFORM_SIMULATION_ID, REFORM_POLICY_ID), + ], + policies: [DATED_REFORM], + isLoading: false, + error: null, + notFound: false, + failedPolicyIds: [], + retry: vi.fn(), + }; +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage opened from a shared link', () => { + beforeEach(() => { + vi.clearAllMocks(); + // No local stash: the provisions are rebuilt from the reform policy. + localStorage.clear(); + store.dispatch(clearMetadata()); + seedMarylandMetadata(); + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + }); + + test('given a 2030 report then the policy changes read current law and the reform in 2030', () => { + // Given + mockUseFlagshipReport.mockReturnValue(sharedReport('2030')); + + // When + renderPage(); + + // Then + const changes = screen.getByRole('region', { name: 'Policy changes' }); + const [row] = within(changes).getAllByRole('row').slice(1); + const cells = within(row).getAllByRole('cell'); + expect(cells.map((cell) => cell.textContent)).toEqual([ + '$550', + '$643', + 'Jan 1, 2027', + 'No end date', + ]); + }); +}); From 2e15b9f1341067be4b77301f1504629e16da5ea1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:13:27 -0400 Subject: [PATCH 03/40] Reforms: the tracker feed reads every bill-status row once Each paged tracker read now names a stable order by the table's key. Without one, page 2 of processed_bills could repeat rows of page 1 and skip others, so bills lost their stage and bill-text link. When two status rows share a bill number, the one with the latest action wins. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 49 +++++++++++++--- app/src/tests/unit/api/billFeed.test.ts | 74 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..517ecd03a 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -186,8 +186,22 @@ function totalRows(response: Response): number | undefined { return total && total !== '*' ? Number(total) : undefined; } +/** + * Each tracker table's row order, by its key. Pages are read by offset, + * and without an order the database may sort each request its own way: + * a second page can then repeat rows of the first and skip others. + */ +const TRACKER_ROW_ORDER = { + research: 'id.asc', + reform_impacts: 'id.asc', + validation_metadata: 'id.asc', + processed_bills: 'bill_id.asc,state.asc,bill_number.asc', +} as const; + +type TrackerTable = keyof typeof TRACKER_ROW_ORDER; + async function trackerSelect( - table: string, + table: TrackerTable, select: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string @@ -199,7 +213,7 @@ async function trackerSelect( // Read page by page: one request stops at the cap and drops the rest. const rows: any[] = []; for (;;) { - const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&order=${TRACKER_ROW_ORDER[table]}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, @@ -560,6 +574,19 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; +/** + * Of two status rows for one bill number, the one whose last action is + * later; on the same date (or none), the later row in the table's order. + */ +function laterStatusRow(current: any, candidate: any): any { + if (!current) { + return candidate; + } + const actionDate = (row: any) => + typeof row?.last_action_date === 'string' ? row.last_action_date : ''; + return actionDate(candidate) >= actionDate(current) ? candidate : current; +} + /** * The tracker's status row (processed_bills) for a research entry. The * entry's LegiScan link (legiscan_bill_id) names the row when it is set. @@ -600,7 +627,10 @@ export async function fetchTrackerBills(): Promise { 'reform_impacts', 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at' ), - trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url'), + trackerSelect( + 'processed_bills', + 'bill_id,state,bill_number,status,legiscan_url,last_action_date' + ), trackerSelect( 'validation_metadata', 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against' @@ -621,12 +651,13 @@ export async function fetchTrackerBills(): Promise { bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); - const processedByKey = new Map( - (processed ?? []).map((bill) => [ - processedBillKey(String(bill.state), String(bill.bill_number)), - bill, - ]) - ); + // Rows that write one bill number apart ("HB 4170", "HB4170") share a key: + // the one with the latest action holds it. + const processedByKey = new Map(); + for (const bill of processed ?? []) { + const key = processedBillKey(String(bill.state), String(bill.bill_number)); + processedByKey.set(key, laterStatusRow(processedByKey.get(key), bill)); + } return research .filter((record) => isBillRecord(record)) diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index fd0c4da9b..44e67c027 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -300,6 +300,80 @@ describe('tracker reads', () => { '1000', ]); }); + + test('given pages read without an order repeat rows then every row still reads once', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const statuses = Array.from({ length: 1418 }, (_, index) => ({ + bill_id: index, + state: 'UT', + bill_number: `HB${index}`, + status: 'Introduced', + })); + statuses[1417] = { bill_id: 1417, state: 'WV', bill_number: 'SB392', status: 'Signed into Law' }; + const fetchMock = vi.fn(async (url: string) => { + const params = new URL(url).searchParams; + const table = new URL(url).pathname.split('/').pop(); + const offset = Number(params.get('offset') ?? 0); + const all: any[] = + table === 'processed_bills' + ? statuses + : table === 'research' + ? [{ id: 'wv-sb392', state: 'WV', type: 'bill', status: 'published', title: 'SB 392' }] + : []; + // Without an order the database picks any row order per request, as + // seen live: page 2 repeats rows 582-999 of page 1 and skips the rest. + const page = + params.get('order') || offset === 0 + ? all.slice(offset, offset + 1000) + : all.slice(offset - 418, offset); + return { + ok: true, + headers: new Headers({ 'content-range': `${offset}-${offset + page.length - 1}/${all.length}` }), + json: async () => page, + }; + }); + vi.stubGlobal('fetch', fetchMock); + + const bills = await fetchTrackerBills(); + + expect(bills?.[0].status).toBe('Signed into Law'); + // Every paged read names a stable order. + for (const [url] of fetchMock.mock.calls) { + expect(new URL(String(url)).searchParams.get('order')).toBeTruthy(); + } + }); + + test('given two status rows for one bill number then the latest action wins', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ + research: [{ id: 'mi-hb4170', state: 'MI', type: 'bill', title: 'MI HB 4170' }], + processed_bills: [ + { + bill_id: 9, + state: 'MI', + bill_number: 'HB 4170', + status: 'Passed', + last_action_date: '2026-06-10', + }, + { + bill_id: 3, + state: 'MI', + bill_number: 'HB4170', + status: 'Passed One Chamber', + last_action_date: '2026-03-02', + }, + ], + }) + ); + + const bills = await fetchTrackerBills(); + + expect(bills?.[0].status).toBe('Passed'); + }); }); describe('fetchTrackerBills', () => { From 303fbf391760c1d55d41f609ad90e7fb69713cb3 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:14:21 -0400 Subject: [PATCH 04/40] Reforms: bills join their stage by the bill number in the title An id with words after the number (hi-sb3125-hd1, nj-a1185-marriage-penalty-elimination, ut-sb60-bill) matched no processed_bills row, so the bill showed no stage. After the id, the join now tries the title's bill number, as the tracker does, and then the id's first part. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 34 ++++++++++++++++++--- app/src/tests/unit/api/billFeed.test.ts | 40 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 517ecd03a..a8cfb5854 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -587,11 +587,24 @@ function laterStatusRow(current: any, candidate: any): any { return actionDate(candidate) >= actionDate(current) ? candidate : current; } +/** + * The bill number a title names, as the tracker reads it: the first + * letters-then-number ("SB3125", "H. 3492", "A 1185"), not a fiscal year. + */ +function titleBillNumber(title: unknown): string | undefined { + return typeof title === 'string' + ? title.match(/\b(?!FY)([A-Z]{1,3}\.?\s*\d+)/i)?.[1] + : undefined; +} + /** * The tracker's status row (processed_bills) for a research entry. The * entry's LegiScan link (legiscan_bill_id) names the row when it is set. * Otherwise the entry's id names the bill: "ut-sb0060", "ny-s-4487", or * "ga-hb463-2026", where a trailing year names the session, not the bill. + * An id with words after the number ("hi-sb3125-hd1", "ut-sb60-bill") + * finds its row as the tracker does: by the bill number in the title, + * then by the id's first part. */ function processedBillFor( record: any, @@ -608,11 +621,24 @@ function processedBillFor( if (!state || parts.length === 0) { return undefined; } - const exact = byKey.get(processedBillKey(state, parts.join(''))); - if (exact || parts.length < 2 || !/^\d{4}$/.test(parts[parts.length - 1])) { - return exact; + const candidates = [parts.join('')]; + if (parts.length >= 2 && /^\d{4}$/.test(parts[parts.length - 1])) { + candidates.push(parts.slice(0, -1).join('')); + } + const titled = titleBillNumber(record.title); + if (titled) { + candidates.push(titled); + } + if (parts.length >= 2 && /^[a-z]+\d+$/i.test(parts[0])) { + candidates.push(parts[0]); + } + for (const billNumber of candidates) { + const row = byKey.get(processedBillKey(state, billNumber)); + if (row) { + return row; + } } - return byKey.get(processedBillKey(state, parts.slice(0, -1).join(''))); + return undefined; } /** diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 44e67c027..7d8b482da 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -582,6 +582,46 @@ describe('fetchTrackerBills', () => { expect(bills![3].legiscanUrl).toBe('https://legiscan.com/KS/bill/HB2620/2026'); }); + test('given an id with words after the bill number then the stage joins by the bill number', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ + research: [ + // The title names the bill, as the tracker reads it. + { id: 'hi-sb3125-hd1', type: 'bill', state: 'HI', title: 'Hawaii SB3125 HD1: tax relief' }, + { + id: 'nj-a1185-marriage-penalty-elimination', + type: 'bill', + state: 'NJ', + title: 'New Jersey A1185: Marriage penalty elimination', + }, + // No bill number in the title: the id's first token names it. + { id: 'ut-sb60-bill', type: 'bill', state: 'UT', title: 'Income tax rate reduction' }, + // A number in the title that names no bill of the state. + { id: 'ok-tax-2026', type: 'bill', state: 'OK', title: 'Oklahoma FY 2026 tax plan' }, + ], + processed_bills: [ + { bill_id: 1, state: 'HI', bill_number: 'SB3125', status: 'Passed Both Chambers' }, + { bill_id: 2, state: 'NJ', bill_number: 'A1185', status: 'In Committee' }, + { bill_id: 3, state: 'UT', bill_number: 'SB0060', status: 'Signed into Law' }, + // The same number in another state is not this bill. + { bill_id: 4, state: 'UT', bill_number: 'A1185', status: 'Vetoed' }, + ], + }) + ); + + const bills = await fetchTrackerBills(); + + expect(bills!.map((bill) => [bill.id, bill.status])).toEqual([ + ['hi-sb3125-hd1', 'Passed Both Chambers'], + ['nj-a1185-marriage-penalty-elimination', 'In Committee'], + ['ut-sb60-bill', 'Signed into Law'], + ['ok-tax-2026', UNTRACKED_BILL_STATUS], + ]); + }); + test('given the tracker stores baseline params then the bills carry them', async () => { vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); From 5704ff03cad8854d1fc4b411161788e07aa0534e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:14:50 -0400 Subject: [PATCH 05/40] Report: a federal bill's stored budget is not called state revenue The stored budget card on Economic impacts now takes the bill and uses the overview's own wording: "Annual revenue loss" for a federal bill, "Annual state revenue loss" for a state bill, with the amount unsigned next to the loss or gain word, as the overview shows it. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/StoredImpactCharts.tsx | 46 +++++++++---------- app/src/libs/flagship/billMetrics.ts | 38 +++++++++++---- app/src/pages/flagship/BillReport.page.tsx | 2 +- .../report/storedImpactChartsMocks.ts | 31 ++++++++++++- .../report/StoredImpactCharts.test.tsx | 39 +++++++++------- .../unit/pages/flagship/BillReport.test.tsx | 14 ++++++ 6 files changed, 117 insertions(+), 53 deletions(-) diff --git a/app/src/components/flagship/report/StoredImpactCharts.tsx b/app/src/components/flagship/report/StoredImpactCharts.tsx index e771851f0..0faa2ba5d 100644 --- a/app/src/components/flagship/report/StoredImpactCharts.tsx +++ b/app/src/components/flagship/report/StoredImpactCharts.tsx @@ -11,9 +11,10 @@ import { } from 'recharts'; import type { TrackedBill, WinnerShares } from '@/api/billFeed'; import ReportChartCard from '@/components/flagship/report/ReportChartCard'; +import type { ReportMetric } from '@/components/flagship/ReportContents'; import { SegmentedControl, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -import { formatCurrencyAbbr } from '@/utils/formatters'; +import { storedRevenueMetric } from '@/libs/flagship/billMetrics'; const WINNER_SEGMENTS = [ { key: 'gainMore', label: 'Gain more than 5%', color: colors.primary[600] }, @@ -98,28 +99,16 @@ const dollars = (value: number) => const signedPercent = (value: number) => `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`; -type StoredBudgetary = NonNullable['budgetary']; - -/** The stored revenue change; as the tracker reads it, net cost stands in where none is stored. */ -const storedRevenue = (budgetary: StoredBudgetary) => - budgetary?.stateRevenueImpact ?? budgetary?.netCost; +type StoredBill = Pick; /** - * The tracker's annual state revenue change, as the report's own headline - * words it: a number, not a chart, across the grid's full width. + * The tracker's annual revenue change, in the overview's own words and + * sign: a number, not a chart, across the grid's full width. */ -function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { - const value = storedRevenue(budgetary); - if (typeof value !== 'number' || !Number.isFinite(value)) { +function StoredBudget({ revenue }: { revenue: ReportMetric | null }) { + if (!revenue) { return null; } - const amount = formatCurrencyAbbr(Math.abs(value), 'us', { maximumFractionDigits: 1 }); - const label = - value < 0 - ? 'Annual state revenue loss' - : value > 0 - ? 'Annual state revenue gain' - : 'Annual state revenue change'; return (
- {value < 0 ? '−' : value > 0 ? '+' : ''} - {amount} + {revenue.value} - {label} + {revenue.label}
@@ -154,8 +142,17 @@ function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { * a bill, in the same chart cards as a report's economic impacts. Shown * while the full results calculate, or instead of them when they cannot. */ -export default function StoredImpactCharts({ impact }: { impact: TrackedBill['impactData'] }) { +export default function StoredImpactCharts({ + bill, + countryId = 'us', +}: { + bill: StoredBill; + countryId?: Parameters[1]; +}) { const [decileMode, setDecileMode] = useState<'average' | 'relative'>('average'); + const impact = bill.impactData; + // Worded as the overview words it, so the two read the same. + const revenue = storedRevenueMetric(bill, countryId); const average = decileRows(impact?.decile?.average); const relative = decileRows(impact?.decile?.relative, 100); @@ -189,9 +186,8 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im return change === null ? [] : [{ group, change }]; }); - const hasBudget = typeof storedRevenue(impact?.budgetary) === 'number'; if ( - !hasBudget && + !revenue && average.length + relative.length + winnersRows.length + povertyRows.length === 0 ) { return null; @@ -206,7 +202,7 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im }} > {/* The budget leads, as it does in the full results. */} - + {(average.length > 0 || relative.length > 0) && ( ; + +/** + * A tracked bill's stored annual revenue change, worded as a loss or a + * gain with the amount unsigned: "$225.5bn", "Annual revenue loss". A + * state bill's figure is that state's revenue; a federal bill's is not. + * Null when the tracker stored none. + */ +export function storedRevenueMetric( + bill: StoredBill, + countryId: Parameters[1] +): ReportMetric | null { + const revenue = bill.impactData?.budgetary?.stateRevenueImpact ?? bill.impacts?.revenue; + if (typeof revenue !== 'number' || !Number.isFinite(revenue)) { + return null; + } + return { + value: formatCurrencyAbbr(Math.abs(revenue), countryId, { maximumFractionDigits: 1 }), + label: `Annual ${bill.state ? 'state ' : ''}revenue ${ + revenue < 0 ? 'loss' : revenue > 0 ? 'gain' : 'change' + }`, + }; +} + /** * A tracked bill's stored headline numbers, shaped like a completed * report's overview metrics so the page reads the same before the full * results arrive. Metrics the tracker did not store are left out. */ export function storedBillMetrics( - bill: Pick, + bill: StoredBill, countryId: Parameters[1] ): ReportMetric[] { const impact = bill.impactData; const metrics: ReportMetric[] = []; - const revenue = impact?.budgetary?.stateRevenueImpact ?? bill.impacts?.revenue; - if (typeof revenue === 'number' && Number.isFinite(revenue)) { - metrics.push({ - value: formatCurrencyAbbr(Math.abs(revenue), countryId, { maximumFractionDigits: 1 }), - // A state bill's stored figure is that state's revenue. - label: `Annual ${bill.state ? 'state ' : ''}revenue ${ - revenue < 0 ? 'loss' : revenue > 0 ? 'gain' : 'change' - }`, - }); + const revenue = storedRevenueMetric(bill, countryId); + if (revenue) { + metrics.push(revenue); } const winners = impact?.winnersLosers; diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 29fe2674c..8474c6a62 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -322,7 +322,7 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) economyPending={ - + } districtsPending={ diff --git a/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts b/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts index c02411e76..ee95eedb9 100644 --- a/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts @@ -1,6 +1,35 @@ import type { TrackedBill } from '@/api/billFeed'; +type StoredBill = Pick; + /** A stored run with a net cost and no state revenue change. */ -export const NET_COST_ONLY_IMPACT: TrackedBill['impactData'] = { +const NET_COST_ONLY_IMPACT: TrackedBill['impactData'] = { budgetary: { netCost: -74_200_000 }, }; + +/** A state bill whose stored run has a net cost only (the feed reads it as the revenue change). */ +export const NET_COST_ONLY_BILL: StoredBill = { + state: 'KS', + impacts: { revenue: -74_200_000 }, + impactData: NET_COST_ONLY_IMPACT, +}; + +/** A state bill that raises its state's revenue. */ +export const STATE_REVENUE_GAIN_BILL: StoredBill = { + state: 'UT', + impacts: { revenue: 11_482_293 }, + impactData: { budgetary: { stateRevenueImpact: 11_482_293, netCost: 11_482_293 } }, +}; + +/** A state bill that costs its state revenue. */ +export const STATE_REVENUE_LOSS_BILL: StoredBill = { + state: 'NY', + impacts: { revenue: -282_165_820 }, + impactData: { budgetary: { stateRevenueImpact: -282_165_820 } }, +}; + +/** A federal bill: the tracker files it with no state. */ +export const FEDERAL_REVENUE_LOSS_BILL: StoredBill = { + impacts: { revenue: -225_500_000_000 }, + impactData: { budgetary: { stateRevenueImpact: -225_500_000_000 } }, +}; diff --git a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx index 9765e9971..a0c0859f9 100644 --- a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx @@ -1,39 +1,46 @@ import { render, screen } from '@test-utils'; import { describe, expect, test } from 'vitest'; import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts'; -import { NET_COST_ONLY_IMPACT } from '@/tests/fixtures/components/flagship/report/storedImpactChartsMocks'; +import { + FEDERAL_REVENUE_LOSS_BILL, + NET_COST_ONLY_BILL, + STATE_REVENUE_GAIN_BILL, + STATE_REVENUE_LOSS_BILL, +} from '@/tests/fixtures/components/flagship/report/storedImpactChartsMocks'; describe('StoredImpactCharts', () => { test('given a stored state revenue change then the budget leads, as a gain or a loss', () => { - render( - - ); + render(); const budget = screen.getByRole('region', { name: 'Budgetary impact' }); - expect(budget).toHaveTextContent('+$11.5m'); - expect(budget).toHaveTextContent('Annual state revenue gain'); + expect(budget).toHaveTextContent('$11.5mAnnual state revenue gain'); }); - test('given a stored revenue loss then it reads as a loss with a minus sign', () => { - render(); + test('given a stored revenue loss then the word says loss and the amount has no sign', () => { + render(); const budget = screen.getByRole('region', { name: 'Budgetary impact' }); - expect(budget).toHaveTextContent('−$282.2m'); - expect(budget).toHaveTextContent('Annual state revenue loss'); + expect(budget).toHaveTextContent('$282.2mAnnual state revenue loss'); + expect(budget).not.toHaveTextContent('−'); + }); + + test('given a federal bill then its revenue is not called state revenue', () => { + render(); + + const budget = screen.getByRole('region', { name: 'Budgetary impact' }); + expect(budget).toHaveTextContent('$225.5bnAnnual revenue loss'); + expect(budget).not.toHaveTextContent(/state revenue/); }); test('given only a stored net cost then it stands in for the revenue change', () => { - render(); + render(); const budget = screen.getByRole('region', { name: 'Budgetary impact' }); - expect(budget).toHaveTextContent('−$74.2m'); - expect(budget).toHaveTextContent('Annual state revenue loss'); + expect(budget).toHaveTextContent('$74.2mAnnual state revenue loss'); }); test('given no stored results then nothing shows', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index ef9423421..e79a9b511 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -132,6 +132,20 @@ describe('BillReportPage', () => { expect(screen.getByText('Stored estimates from the legislative tracker')).toBeInTheDocument(); }); + test('given a federal bill then its stored budget reads as the overview does, not as state revenue', async () => { + // Given + const user = userEvent.setup(); + renderReport(); + + // When + await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); + + // Then + const budget = screen.getByRole('region', { name: 'Budgetary impact' }); + expect(budget).toHaveTextContent('$225.5bnAnnual revenue loss'); + expect(budget).not.toHaveTextContent(/state revenue|−/); + }); + test('given the page and its tabs open then no full run starts until asked', async () => { const user = userEvent.setup(); seedDeductionMetadata(); From e5f7b37dabcc097c67048825104f12d3e48f54c6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:16 -0400 Subject: [PATCH 06/40] Reforms: the tracker feed reads its pages in a fixed row order Each paged tracker read now names a unique order column (bill_id for processed_bills, id for the rest). Without one, Postgres gave page 2 of processed_bills rows already on page 1, so about 418 of 1,418 status rows were lost on each load, and a different set each time. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 22 ++-- app/src/tests/fixtures/api/billFeedMocks.ts | 105 ++++++++++++++++++++ app/src/tests/unit/api/billFeed.test.ts | 41 ++++++++ 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..42ed3a68e 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -189,6 +189,13 @@ function totalRows(response: Response): number | undefined { async function trackerSelect( table: string, select: string, + /** + * A unique column that puts the rows in one fixed order ("bill_id"). + * With no order, the database can give each page its rows in another + * order, so the pages repeat some rows and drop others, and different + * ones on each load. + */ + order: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string ): Promise { @@ -199,7 +206,7 @@ async function trackerSelect( // Read page by page: one request stops at the cap and drops the rest. const rows: any[] = []; for (;;) { - const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&order=${order}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, @@ -500,6 +507,7 @@ export async function fetchTrackerBillDistricts( const rows = await trackerSelect( 'reform_impacts', 'district_impacts', + 'id', `id=eq.${encodeURIComponent(billId)}` ); const raw = rows?.[0]?.district_impacts; @@ -595,19 +603,21 @@ function processedBillFor( export async function fetchTrackerBills(): Promise { const [research, impacts, processed, validations, baselines] = await Promise.all([ // Titled as the tracker shows them: an entry with no title reads as its bill number. - trackerSelect('research', '*').then((rows) => rows?.map(withTrackerTitle) ?? null), + trackerSelect('research', '*', 'id').then((rows) => rows?.map(withTrackerTitle) ?? null), trackerSelect( 'reform_impacts', - 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at' + 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at', + 'id' ), - trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url'), + trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url', 'bill_id'), trackerSelect( 'validation_metadata', - 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against' + 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against', + 'id' ), // The law each bill was compared with. Read on its own, so a tracker // without the column yet still serves the rest of the feed. - trackerSelect('reform_impacts', 'id,baseline_params').catch(() => null), + trackerSelect('reform_impacts', 'id,baseline_params', 'id').catch(() => null), ]); if (!research) { return null; diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 1c4dc74bb..c9bc23a47 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -45,3 +45,108 @@ export const NET_COST_DRIFT_ROWS: TrackerTableRows = { }, ], }; + +/** The most rows one tracker read returns, as the Supabase cap sets it. */ +export const TRACKER_PAGE_LIMIT = 1000; + +/** The tracker's bill-status table holds more rows than one read returns. */ +export const STATUS_TABLE_ROWS = 1418; + +/** The first column a PostgREST `order` names ("bill_id" in "bill_id.asc"). */ +const orderColumn = (order: string) => order.split(',')[0].split('.')[0]; + +const byColumn = (column: string) => (a: any, b: any) => + a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0; + +/** + * A fetch that serves each tracker table a page at a time, as PostgREST + * does. A read that names an `order` gets its rows in that order. A read + * with none has no fixed row order: as on the live tracker, every page + * starts from the table's first rows again, so a later page repeats rows + * already read and the rest never arrive. + */ +export const mockPagedTrackerFetch = (rows: TrackerTableRows) => + vi.fn(async (url: string) => { + const { pathname, searchParams } = new URL(url); + const all = rows[pathname.split('/').pop() ?? ''] ?? []; + const order = searchParams.get('order'); + const offset = Number(searchParams.get('offset') ?? 0); + const limit = Number(searchParams.get('limit') ?? all.length); + const count = Math.max(0, Math.min(limit, all.length - offset)); + const page = order + ? [...all].sort(byColumn(orderColumn(order))).slice(offset, offset + count) + : all.slice(0, count); + return { + ok: true, + headers: new Headers({ + 'content-range': `${offset}-${offset + page.length - 1}/${all.length}`, + }), + json: async () => page, + }; + }); + +/** WV SB 392, whose one status row is the bill-status table's last. */ +export const LATE_STATUS_BILL = { + id: 'wv-sb392', + state: 'WV', + type: 'bill', + title: 'WV SB 392', +} as const; +export const LATE_STATUS = { + status: 'Signed into Law', + legiscanUrl: 'https://legiscan.com/WV/bill/SB392/2026', +} as const; + +/** A full bill-status table with WV SB 392's row past the first page. */ +export const LATE_STATUS_ROWS: TrackerTableRows = { + research: [LATE_STATUS_BILL], + processed_bills: Array.from({ length: STATUS_TABLE_ROWS }, (_, index) => + index === STATUS_TABLE_ROWS - 1 + ? { + bill_id: 2_000_000 + index, + state: 'WV', + bill_number: 'SB392', + status: LATE_STATUS.status, + legiscan_url: LATE_STATUS.legiscanUrl, + } + : { + bill_id: 2_000_000 + index, + state: 'OK', + bill_number: `HB${1000 + index}`, + status: 'Introduced', + } + ), +}; + +/** Bills enough that every tracker table runs past one page. */ +export const MANY_BILLS = TRACKER_PAGE_LIMIT + 1; +export const MANY_BILLS_STATUS = 'In Committee'; + +const MANY_BILL_NUMBERS = Array.from({ length: MANY_BILLS }, (_, index) => 1000 + index); + +/** Every table past one page: each bill has its entry, run, check and status row. */ +export const MANY_BILL_ROWS: TrackerTableRows = { + research: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + state: 'OK', + type: 'bill', + title: `OK HB ${number}`, + })), + reform_impacts: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + computed: true, + budgetary_impact: { stateRevenueImpact: -number * 1000 }, + })), + validation_metadata: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + pe_estimate: -number * 1000, + fiscal_note_estimate: -number * 1000, + within_range: true, + })), + processed_bills: MANY_BILL_NUMBERS.map((number) => ({ + bill_id: 3_000_000 + number, + state: 'OK', + bill_number: `HB ${number}`, + status: MANY_BILLS_STATUS, + })), +}; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index fd0c4da9b..b49b317d2 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -9,6 +9,12 @@ import { } from '@/api/billFeed'; import { CURRENT_YEAR } from '@/constants'; import { + LATE_STATUS, + LATE_STATUS_ROWS, + MANY_BILL_ROWS, + MANY_BILLS, + MANY_BILLS_STATUS, + mockPagedTrackerFetch, mockTrackerFetch, NET_COST_DRIFT_ROWS, NET_COST_ONLY_ROWS, @@ -300,6 +306,41 @@ describe('tracker reads', () => { '1000', ]); }); + + test('given a status row past the first page then every load finds its stage and link', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + // Pages read with no fixed order repeat rows and skip the rest. + vi.stubGlobal('fetch', mockPagedTrackerFetch(LATE_STATUS_ROWS)); + + const [bill] = (await fetchTrackerBills())!; + + expect([bill.status, bill.legiscanUrl]).toEqual([LATE_STATUS.status, LATE_STATUS.legiscanUrl]); + }); + + test('given every table past one page then each bill reads once, with its run, check and stage', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockPagedTrackerFetch(MANY_BILL_ROWS)); + + const bills = (await fetchTrackerBills())!; + + const billsWith = (has: (bill: (typeof bills)[number]) => boolean) => + new Set(bills.filter(has).map((bill) => bill.id)).size; + expect({ + listed: bills.length, + unique: billsWith(() => true), + withRun: billsWith((bill) => bill.impacts?.revenue !== undefined), + withCheck: billsWith((bill) => bill.validation?.withinRange === true), + withStage: billsWith((bill) => bill.status === MANY_BILLS_STATUS), + }).toEqual({ + listed: MANY_BILLS, + unique: MANY_BILLS, + withRun: MANY_BILLS, + withCheck: MANY_BILLS, + withStage: MANY_BILLS, + }); + }); }); describe('fetchTrackerBills', () => { From 075a4d713671db70a9f1cd48828a6d19fd594780 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:40 -0400 Subject: [PATCH 07/40] Reforms: a bill opened as a draft keeps its run year and state Open as draft reform started a national draft for this year, so a bill from 2027 scored no change from Build and a state bill ran nationwide. The draft now opens with the bill's run year and state, as the bill's report and Run impact report do. loadProvisionsIntoDraft takes an opt-in source; without one the draft stays hand-built. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/draftReform.ts | 18 +++++-- app/src/pages/flagship/Reforms.page.tsx | 20 ++++---- app/src/tests/unit/libs/draftReform.test.ts | 16 ++++++ .../unit/pages/flagship/Reforms.test.tsx | 50 +++++++++++++++++++ 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/app/src/libs/draftReform.ts b/app/src/libs/draftReform.ts index 9b28df649..d12879874 100644 --- a/app/src/libs/draftReform.ts +++ b/app/src/libs/draftReform.ts @@ -271,18 +271,30 @@ export function clearDraftAfterRun(userReportId: string): void { } } -/** A report's reform back in the draft, to edit and run again. */ +/** + * A report's reform back in the draft, to edit and run again — or a + * bill's, with the bill as its source. + */ export function loadProvisionsIntoDraft( countryId: CountryId, provisions: DraftProvision[], - options: { label?: string; year?: number; region?: string; baselineReformId?: string | null } = {} + options: { + label?: string; + year?: number; + region?: string; + baselineReformId?: string | null; + /** Where the reform came from; hand-built when unset. */ + source?: ReformSource; + sourceRef?: string; + } = {} ): void { writeDraft({ countryId, label: options.label ?? '', provisions: provisions.map((provision) => ({ ...provision })), population: { scope: 'national', ...(options.region ? { region: options.region } : {}) }, - source: 'manual', + source: options.source ?? 'manual', + ...(options.sourceRef ? { sourceRef: options.sourceRef } : {}), startedAt: Date.now(), ...(options.year ? { year: options.year } : {}), ...(options.baselineReformId ? { baselineReformId: options.baselineReformId } : {}), diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4667dccfb..09284aaca 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -39,12 +39,7 @@ import { billRegion } from '@/hooks/useBillEconomy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { useTrackedBills } from '@/hooks/useTrackedBills'; -import { - addDraftProvision, - clearDraftReform, - loadReformIntoDraft, - setDraftLabel, -} from '@/libs/draftReform'; +import { loadProvisionsIntoDraft, loadReformIntoDraft } from '@/libs/draftReform'; import { BillFilters, billPlace, filterBills, NO_BILL_FILTERS } from '@/libs/flagship/billFilters'; import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; import { periodLabel, summarizeIntervals } from '@/libs/flagship/draftLabels'; @@ -470,11 +465,14 @@ export default function ReformsPage() { }; const openBillAsDraft = (bill: TrackedBill) => { - clearDraftReform(); - billProvisions(bill).forEach((provision) => - addDraftProvision(countryId, provision, 'bill', bill.id) - ); - setDraftLabel(bill.title); + // The draft runs as the bill's report does: in its first year, on its state. + loadProvisionsIntoDraft(countryId, billProvisions(bill), { + label: bill.title, + year: billRunYear(bill.provisions), + region: bill.state ? billRegion(bill, countryId) : undefined, + source: 'bill', + sourceRef: bill.id, + }); // The draft is Build's page now, not a panel beside this one. nav.push(`/${countryId}/build`); }; diff --git a/app/src/tests/unit/libs/draftReform.test.ts b/app/src/tests/unit/libs/draftReform.test.ts index 8af5babce..b120927e5 100644 --- a/app/src/tests/unit/libs/draftReform.test.ts +++ b/app/src/tests/unit/libs/draftReform.test.ts @@ -160,6 +160,22 @@ describe('draftReform', () => { expect(getDraftReform()).not.toHaveProperty('baselineReformId'); }); + it('given provisions loaded with no source then the draft is hand-built', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }]); + + expect(getDraftReform()?.source).toBe('manual'); + expect(getDraftReform()).not.toHaveProperty('sourceRef'); + }); + + it('given provisions loaded from a bill then the draft keeps the bill as its source', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }], { + source: 'bill', + sourceRef: 'us-hr1425', + }); + + expect(getDraftReform()).toMatchObject({ source: 'bill', sourceRef: 'us-hr1425' }); + }); + it('given a search entry and metadata values then provisionFromSearchEntry uses the current value as baseline', () => { const provision = provisionFromSearchEntry( { path: 'gov.x', breadcrumb: 'X', unit: 'currency-USD' }, diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index f707afe4e..7a32fc4b0 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, renderWithCountry, screen, userEvent, waitFor, within } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CURRENT_YEAR } from '@/constants'; import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; import ReformsPage from '@/pages/flagship/Reforms.page'; import { @@ -173,6 +174,25 @@ const FEED_BILLS = [ }, ]; +/** A state bill that first takes effect next year (live: RI S2364, from 2027). */ +const LATER_STATE_BILL = { + id: 'ri-s2364', + countryId: 'us' as const, + jurisdiction: 'Rhode Island', + state: 'RI', + title: 'Rhode Island S2364: income tax rate cut', + status: 'In Committee', + summary: 'Cuts the lowest income tax rate from 2027.', + provisions: [ + { + path: 'gov.states.ri.tax.income.rates[0].rate', + value: 0.0375, + intervals: [{ startDate: '2027-01-01', endDate: '2100-12-31', value: 0.0375 }], + fallbackBreadcrumb: 'Rhode Island → Income tax → Lowest rate', + }, + ], +}; + function renderReforms(path?: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const ui = ( @@ -289,6 +309,36 @@ describe('ReformsPage', () => { expect(draft?.provisions.length).toBeGreaterThan(0); }); + test('given a state bill from a later year opens as a draft then the draft runs as its report does', async () => { + const user = userEvent.setup(); + mockFetchTrackerBills.mockResolvedValue([...FEED_BILLS, LATER_STATE_BILL]); + renderReforms(`/us/reforms?bill=${LATER_STATE_BILL.id}`); + + await user.click(await screen.findByRole('button', { name: /open as draft reform/i })); + + // The bill's first year and its state, not this year and the whole country. + expect(getDraftReform()).toMatchObject({ + label: LATER_STATE_BILL.title, + year: 2027, + population: { scope: 'national', region: 'state/ri' }, + source: 'bill', + sourceRef: LATER_STATE_BILL.id, + provisions: [{ path: 'gov.states.ri.tax.income.rates[0].rate', value: 0.0375 }], + }); + }); + + test('given a federal bill opens as a draft then the draft runs on the whole country', async () => { + const user = userEvent.setup(); + renderReforms('/us/reforms?bill=us-ctc-expansion'); + + await user.click(await screen.findByRole('button', { name: /open as draft reform/i })); + + const draft = getDraftReform(); + expect(draft?.year).toBe(Number(CURRENT_YEAR)); + expect(draft?.population).toEqual({ scope: 'national' }); + expect(draft?.sourceRef).toBe('us-ctc-expansion'); + }); + test('given the federal level then state bills leave', async () => { const user = userEvent.setup(); renderReforms(); From 9984e07daf888e45d72e1e6100b6fd96369b6274 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:41 -0400 Subject: [PATCH 08/40] Reforms: a bill filed twice under one number keeps the later bill's stage When two processed_bills rows share one normalized bill number (NY "S 4487" and "S04487"), the feed kept whichever row it read last. It now keeps the row with the higher LegiScan bill id, the later bill, so the stage and the bill text link no longer depend on the read order. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 31 +++++++++++++++++++-- app/src/tests/fixtures/api/billFeedMocks.ts | 23 +++++++++++++++ app/src/tests/unit/api/billFeed.test.ts | 23 +++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 42ed3a68e..b6dd0c460 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -568,6 +568,31 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; +/** A status row's LegiScan bill id as a number, lowest when it has none. */ +const billAge = (bill: any): number => { + const id = Number(bill.bill_id ?? NaN); + return Number.isFinite(id) ? id : -Infinity; +}; + +const statusRowText = (bill: any) => + JSON.stringify([bill.bill_id, bill.state, bill.bill_number, bill.status, bill.legiscan_url]); + +/** + * Sorts status rows so the later bill comes last. LegiScan numbers bills + * in the order it first sees them, so the higher bill id is the later + * bill; rows with the same id, or none, fall back to their contents. + * Two rows under one bill number (NY "S 4487" and "S04487") then resolve + * to the same row whatever order the tracker sent them in. + */ +function byLaterBill(a: any, b: any): number { + const [ageA, ageB] = [billAge(a), billAge(b)]; + if (ageA !== ageB) { + return ageA < ageB ? -1 : 1; + } + const [textA, textB] = [statusRowText(a), statusRowText(b)]; + return textA < textB ? -1 : textA > textB ? 1 : 0; +} + /** * The tracker's status row (processed_bills) for a research entry. The * entry's LegiScan link (legiscan_bill_id) names the row when it is set. @@ -626,13 +651,15 @@ 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])); + // The later bill last, so it is the row each map keeps for a shared key. + const processedRows = [...(processed ?? [])].sort(byLaterBill); const processedByBillId = new Map( - (processed ?? []).flatMap((bill) => + processedRows.flatMap((bill) => bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); const processedByKey = new Map( - (processed ?? []).map((bill) => [ + processedRows.map((bill) => [ processedBillKey(String(bill.state), String(bill.bill_number)), bill, ]) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index c9bc23a47..5247f83a6 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -150,3 +150,26 @@ export const MANY_BILL_ROWS: TrackerTableRows = { status: MANY_BILLS_STATUS, })), }; + +/** NY S 4487, filed twice in the status table under two spellings of its number. */ +export const TWICE_FILED_BILL = { + id: 'ny-s-4487', + state: 'NY', + type: 'bill', + title: 'NY S 4487', +} as const; +/** The later of the two rows: LegiScan gives a later bill a higher id. */ +export const TWICE_FILED_LATER = { + bill_id: 1_987_654, + state: 'NY', + bill_number: 'S04487', + status: 'In Committee', + legiscan_url: 'https://legiscan.com/NY/bill/S04487/2025', +} as const; +export const TWICE_FILED_EARLIER = { + bill_id: 1_765_432, + state: 'NY', + bill_number: 'S 4487', + status: 'Introduced', + legiscan_url: 'https://openstates.org/ny/bills/2025-2026/S4487/', +} as const; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index b49b317d2..4d9dba7eb 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -19,6 +19,9 @@ import { NET_COST_DRIFT_ROWS, NET_COST_ONLY_ROWS, TRACKER_NAMING_ROWS, + TWICE_FILED_BILL, + TWICE_FILED_EARLIER, + TWICE_FILED_LATER, } from '@/tests/fixtures/api/billFeedMocks'; import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; @@ -341,6 +344,26 @@ describe('tracker reads', () => { withStage: MANY_BILLS, }); }); + + test('given two status rows under one bill number then the later bill wins in any read order', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const loads = []; + for (const processed of [ + [TWICE_FILED_EARLIER, TWICE_FILED_LATER], + [TWICE_FILED_LATER, TWICE_FILED_EARLIER], + ]) { + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ research: [TWICE_FILED_BILL], processed_bills: processed }) + ); + const [bill] = (await fetchTrackerBills())!; + loads.push([bill.status, bill.legiscanUrl]); + } + + const later = [TWICE_FILED_LATER.status, TWICE_FILED_LATER.legiscan_url]; + expect(loads).toEqual([later, later]); + }); }); describe('fetchTrackerBills', () => { From 1ecbe59f0dc717d6e38cd436af5bfa37c5804792 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:16:50 -0400 Subject: [PATCH 09/40] Report: a federal bill's stored budget is not called state revenue The Economic impacts tab labelled every stored budget 'Annual state revenue', so HR 1425's -$225.5bn read as state revenue while the Overview said 'Annual revenue loss'. The stored budget now takes the bill's state and words it as the Overview does. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/StoredImpactCharts.tsx | 27 +++++++++++-------- app/src/pages/flagship/BillReport.page.tsx | 2 +- .../report/StoredImpactCharts.test.tsx | 17 ++++++++++-- .../unit/pages/flagship/BillReport.test.tsx | 13 +++++++++ 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/app/src/components/flagship/report/StoredImpactCharts.tsx b/app/src/components/flagship/report/StoredImpactCharts.tsx index e771851f0..c75e84f46 100644 --- a/app/src/components/flagship/report/StoredImpactCharts.tsx +++ b/app/src/components/flagship/report/StoredImpactCharts.tsx @@ -105,21 +105,19 @@ const storedRevenue = (budgetary: StoredBudgetary) => budgetary?.stateRevenueImpact ?? budgetary?.netCost; /** - * The tracker's annual state revenue change, as the report's own headline - * words it: a number, not a chart, across the grid's full width. + * The tracker's annual revenue change, as the report's own headline words + * it: a number, not a chart, across the grid's full width. A state bill's + * figure is that state's revenue; a federal bill's is not. */ -function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { +function StoredBudget({ budgetary, state }: { budgetary: StoredBudgetary; state?: string }) { const value = storedRevenue(budgetary); if (typeof value !== 'number' || !Number.isFinite(value)) { return null; } const amount = formatCurrencyAbbr(Math.abs(value), 'us', { maximumFractionDigits: 1 }); - const label = - value < 0 - ? 'Annual state revenue loss' - : value > 0 - ? 'Annual state revenue gain' - : 'Annual state revenue change'; + const label = `Annual ${state ? 'state ' : ''}revenue ${ + value < 0 ? 'loss' : value > 0 ? 'gain' : 'change' + }`; return (
('average'); const average = decileRows(impact?.decile?.average); @@ -206,7 +211,7 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im }} > {/* The budget leads, as it does in the full results. */} - + {(average.length > 0 || relative.length > 0) && ( - + } districtsPending={ diff --git a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx index 9765e9971..b712be296 100644 --- a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx @@ -8,6 +8,7 @@ describe('StoredImpactCharts', () => { render( ); @@ -17,7 +18,9 @@ describe('StoredImpactCharts', () => { }); test('given a stored revenue loss then it reads as a loss with a minus sign', () => { - render(); + render( + + ); const budget = screen.getByRole('region', { name: 'Budgetary impact' }); expect(budget).toHaveTextContent('−$282.2m'); @@ -25,13 +28,23 @@ describe('StoredImpactCharts', () => { }); test('given only a stored net cost then it stands in for the revenue change', () => { - render(); + render(); const budget = screen.getByRole('region', { name: 'Budgetary impact' }); expect(budget).toHaveTextContent('−$74.2m'); expect(budget).toHaveTextContent('Annual state revenue loss'); }); + test('given a federal bill then its stored revenue change is not called state revenue', () => { + // As the Overview words it for HR 1425: the federal budget, not a state's. + render(); + + const budget = screen.getByRole('region', { name: 'Budgetary impact' }); + expect(budget).toHaveTextContent('−$225.5bn'); + expect(budget).toHaveTextContent('Annual revenue loss'); + expect(budget).not.toHaveTextContent(/state revenue/i); + }); + test('given no stored results then nothing shows', () => { const { container } = render(); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index ef9423421..da5b2a263 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -154,6 +154,19 @@ describe('BillReportPage', () => { ).toBeInTheDocument(); }); + test('given a federal bill then economic impacts word its stored budget as the overview does', async () => { + const user = userEvent.setup(); + renderReport(); + + await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); + + const budget = within(screen.getByRole('tabpanel')).getByRole('region', { + name: 'Budgetary impact', + }); + expect(budget).toHaveTextContent('Annual revenue loss'); + expect(budget).not.toHaveTextContent(/state revenue/i); + }); + test('given the overview then summary, date and bill text show; the verdict is in Validation', async () => { const user = userEvent.setup(); renderReport(); From eb5314fc6bc7a094892020c00a580e103f13da0d Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:18:13 -0400 Subject: [PATCH 10/40] Reforms: deleting a saved reform asks first and reports a failure Delete in the reform detail now opens a confirm dialog that names the reform and says how many of its reports leave the list (they still open from their links). A failed delete or duplicate shows an error, and the error clears when another reform opens. Co-Authored-By: Claude Opus 5.5 --- .../flagship/DeleteReformDialog.tsx | 62 +++++++++ app/src/pages/flagship/Reforms.page.tsx | 28 ++-- .../unit/pages/flagship/Reforms.test.tsx | 131 +++++++++++++++++- 3 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 app/src/components/flagship/DeleteReformDialog.tsx diff --git a/app/src/components/flagship/DeleteReformDialog.tsx b/app/src/components/flagship/DeleteReformDialog.tsx new file mode 100644 index 000000000..e6d7b0cf5 --- /dev/null +++ b/app/src/components/flagship/DeleteReformDialog.tsx @@ -0,0 +1,62 @@ +import { IconTrash } from '@tabler/icons-react'; +import { AlertDialog } from 'radix-ui'; +import { Button } from '@/components/ui'; + +/** What the delete does to the reports run from the reform. */ +function reportsNote(reportCount: number): string { + if (reportCount === 0) { + return ''; + } + return reportCount === 1 + ? ' Its 1 report leaves this list; it still opens from its link.' + : ` Its ${reportCount} reports leave this list; they still open from their links.`; +} + +/** + * The reform detail's Delete button. A delete cannot be undone, and the + * reform's reports are listed only under it, so the button asks first. + */ +export default function DeleteReformDialog({ + name, + reportCount, + disabled, + onConfirm, +}: { + name: string; + /** The reports this browser ran from the reform. */ + reportCount: number; + disabled?: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + + + Delete “{name}”? + + + This removes the reform for good.{reportsNote(reportCount)} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4667dccfb..92b8c676f 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -9,7 +9,6 @@ import { IconPlus, IconScale, IconSearch, - IconTrash, } from '@tabler/icons-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; @@ -18,6 +17,7 @@ import { getFlagshipReportStore } from '@/api/flagshipReportStore'; import { getReformStore } from '@/api/reformStore'; import BillFeedFilters from '@/components/flagship/bills/BillFeedFilters'; import BillStageBadge from '@/components/flagship/bills/BillStageBadge'; +import DeleteReformDialog from '@/components/flagship/DeleteReformDialog'; import ProvisionList from '@/components/flagship/ProvisionList'; import ReformReports from '@/components/flagship/ReformReports'; import { @@ -464,6 +464,10 @@ export default function ReformsPage() { : null; const openReform = (reform: Reform) => { + // A failed save, delete or copy belongs to the reform it was tried on. + saveMutation.reset(); + deleteMutation.reset(); + duplicateMutation.reset(); setSelectedId(reform.id!); setEditedLabel(reform.label ?? ''); setEditedValues(Object.fromEntries(reform.parameters.map((p) => [p.name, p.values[0]?.value]))); @@ -776,6 +780,16 @@ export default function ReformsPage() { Could not save the changes. Try again. )} + {deleteMutation.isError && ( + + Could not delete the reform. Try again. + + )} + {duplicateMutation.isError && ( + + Could not duplicate the reform. Try again. + + )} Duplicate - + onConfirm={() => deleteMutation.mutate(selectedReform.id!)} + /> {runReport.error && ( {runReport.error} diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index f707afe4e..0f8b89140 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -650,18 +650,147 @@ describe('ReformsPage', () => { ).toBeInTheDocument(); }); - test('given delete in the reform detail then the store delete is called', async () => { + test('given delete in the reform detail then it asks first and deletes nothing yet', async () => { + // Given mockFindMine.mockResolvedValue([CHAT_REFORM]); mockDelete.mockResolvedValue(undefined); const user = userEvent.setup(); renderReforms('/us/reforms?filter=yours'); await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + // When await user.click(screen.getByRole('button', { name: /delete/i })); + // Then + const dialog = screen.getByRole('alertdialog', { + name: 'Delete “CTC to $3,600 for children under 6”?', + }); + expect(dialog).toBeInTheDocument(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + test('given the delete is confirmed then the store delete is called', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM]); + mockDelete.mockResolvedValue(undefined); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + await user.click(screen.getByRole('button', { name: /delete/i })); + + // When + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete reform' }) + ); + + // Then expect(mockDelete).toHaveBeenCalledWith('rf-1'); }); + test('given the delete is cancelled then the reform stays', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + await user.click(screen.getByRole('button', { name: /delete/i })); + + // When + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }) + ); + + // Then + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(mockDelete).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('CTC to $3,600 for children under 6')).toBeInTheDocument(); + }); + + test('given a reform with reports then the delete prompt says they stay reachable by link', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM]); + mockFindReports.mockResolvedValue([ + { + id: 'r-a', + countryId: 'us', + apiReportId: '901', + title: 'CTC', + sourceNote: null, + provisions: [], + year: '2026', + createdAt: '2026-10-13T15:00:00Z', + reformId: 'rf-1', + }, + ]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + await screen.findByText('2026 report'); + + // When + await user.click(screen.getByRole('button', { name: /delete/i })); + + // Then + expect(screen.getByRole('alertdialog')).toHaveTextContent( + 'Its 1 report leaves this list; it still opens from its link.' + ); + }); + + test('given the delete fails then the detail says so', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM]); + mockDelete.mockRejectedValue(new Error('503')); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + await user.click(screen.getByRole('button', { name: /delete/i })); + + // When + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete reform' }) + ); + + // Then + expect(await screen.findByText(/could not delete/i)).toBeInTheDocument(); + }); + + test('given a failed delete then another reform opens with no error', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM, LATER_REFORM]); + mockDelete.mockRejectedValue(new Error('503')); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + await user.click(screen.getByRole('button', { name: /delete/i })); + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete reform' }) + ); + await screen.findByText(/could not delete/i); + + // When + await user.click(screen.getByRole('button', { name: /all reforms/i })); + await user.click(await screen.findByText('CTC to $1,000 from 2028')); + + // Then + expect(screen.getByDisplayValue('CTC to $1,000 from 2028')).toBeInTheDocument(); + expect(screen.queryByText(/could not delete/i)).not.toBeInTheDocument(); + }); + + test('given the duplicate fails then the detail says so', async () => { + // Given + mockFindMine.mockResolvedValue([CHAT_REFORM]); + mockCreate.mockRejectedValue(new Error('503')); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + + // When + await user.click(screen.getByRole('button', { name: /duplicate/i })); + + // Then + expect(await screen.findByText(/could not duplicate/i)).toBeInTheDocument(); + }); + test('given duplicate in the reform detail then a copy is created', async () => { mockFindMine.mockResolvedValue([CHAT_REFORM]); mockCreate.mockResolvedValue({ id: 'rf-copy' }); From cab31f94bc16654860e04ff9911c90640cf06f8c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:18:31 -0400 Subject: [PATCH 11/40] Validation: the fiscal note names its source and links it The tracker's fiscal_note_source was read but never shown, and a note stored as a range without a point estimate lost its link, so 'Within fiscal-note range' cited a note no one could open. The fiscal-note row now takes the stored source as its name, and a note without an estimate is named and linked under the accepted range, or in a row of its own when no range is stored. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ValidationPanel.tsx | 56 ++++++++++---- app/src/libs/flagship/modelValidation.ts | 10 ++- .../flagship/ValidationPanel.test.tsx | 73 +++++++++++++++++++ .../libs/flagship/modelValidation.test.ts | 10 +++ 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index 1e24efaa9..72b9c985b 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -6,6 +6,7 @@ import { colors, spacing, typography } from '@/designTokens'; import { relevantComparisons, type ReformScope } from '@/libs/flagship/comparisonRelevance'; import { claimsFromBillValidation, + fiscalNoteLabel, METRIC_LABELS, ModelValidationRow, PROGRAM_LABELS, @@ -124,9 +125,27 @@ const VERIFICATION_COPY: Record['ove unverifiable: 'Sources could not be independently re-verified', }; +/** A link to an estimate's source, under its name. */ +function ReadSource({ href }: { href: string }) { + return ( + + + Read source + + + ); +} + /** * A bill's external estimates beside PolicyEngine's, in one table, with * the range verdict and what the pipeline noted about the comparison. + * A fiscal note stored without a single estimate still names and links + * its source: under the accepted range it sets, or in a row of its own. */ export function BillExternalEstimates({ billId, @@ -143,6 +162,10 @@ export function BillExternalEstimates({ typeof validation.targetRangeLow === 'number' && typeof validation.targetRangeHigh === 'number' ? [validation.targetRangeLow, validation.targetRangeHigh].sort((a, b) => a - b) : null; + const noteSource = validation.fiscalNoteSource?.trim(); + const noteWithoutEstimate = + typeof validation.fiscalNoteEstimate !== 'number' && + (validation.fiscalNoteUrl !== undefined || Boolean(noteSource)); return ( @@ -157,31 +180,34 @@ export function BillExternalEstimates({ — )} - {claims.map((claim) => ( - + {claims.map((claim, index) => ( + {claim.source} - {claim.sourceUrl && ( - - - Read source - - - )} + {claim.sourceUrl && } {compactMoney(claim.externalValue)} {gapText(peEstimate, claim.externalValue)} ))} + {noteWithoutEstimate && !range && ( + + + {fiscalNoteLabel(validation)} + {validation.fiscalNoteUrl && } + + No estimate + — + + )} {range && ( - Accepted range + Accepted range + {noteWithoutEstimate && noteSource && {noteSource}} + {noteWithoutEstimate && validation.fiscalNoteUrl && ( + + )} {compactMoney(range[0])} to {compactMoney(range[1])} diff --git a/app/src/libs/flagship/modelValidation.ts b/app/src/libs/flagship/modelValidation.ts index 9cbcbedc6..c4d827d4f 100644 --- a/app/src/libs/flagship/modelValidation.ts +++ b/app/src/libs/flagship/modelValidation.ts @@ -248,11 +248,17 @@ export interface ValidationClaim { notes?: string; } +/** A fiscal note's label: who wrote it, as the tracker stored it, or the generic name. */ +export function fiscalNoteLabel(validation: { fiscalNoteSource?: string }): string { + return validation.fiscalNoteSource?.trim() || 'Official fiscal note'; +} + /** Tracker validation_metadata → scorecard-shaped claims. */ export function claimsFromBillValidation( billId: string, validation: { fiscalNoteEstimate?: number; + fiscalNoteSource?: string; fiscalNoteUrl?: string; peEstimate?: number; discrepancyExplanation?: string; @@ -276,7 +282,9 @@ export function claimsFromBillValidation( return [ ...(typeof validation.fiscalNoteEstimate === 'number' ? [ - claim('Official fiscal note', validation.fiscalNoteEstimate, { + // Named by its source: a proxy note (another bill's score) must not + // read as this bill's own. + claim(fiscalNoteLabel(validation), validation.fiscalNoteEstimate, { sourceUrl: validation.fiscalNoteUrl, notes: validation.discrepancyExplanation, }), diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index 990e3e827..e6d90a73d 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -1,5 +1,6 @@ import { render, screen, within } from '@test-utils'; import { describe, expect, test } from 'vitest'; +import type { BillValidation } from '@/api/billFeed'; import { BaselineComparisons, BillExternalEstimates, @@ -73,6 +74,17 @@ const SNAP_AND_CTC_TRACK_RECORD: ScorecardMatches = { ], }; +/** A fiscal note stored as a range with its source and link, but no point estimate. */ +const RANGE_ONLY_FISCAL_NOTE: BillValidation = { + peEstimate: -800_583_615, + fiscalNoteSource: 'GA Dept. of Audits & Accounts / OPB', + fiscalNoteUrl: 'https://opb.georgia.gov/fiscal-notes/hb1001', + targetRangeLow: -840_000_000, + targetRangeHigh: -680_000_000, + withinRange: true, + differencePct: 5.3, +}; + describe('BillExternalEstimates', () => { test('given a bill with a fiscal note then every estimate shares one aligned table', () => { render( @@ -92,6 +104,67 @@ describe('BillExternalEstimates', () => { expect(screen.getByText(/later effective date/)).toBeInTheDocument(); }); + test('given a fiscal note with a range and no point estimate then the range names and links it', () => { + // As ga-hb1001 stores it: the note's range, its source and its link, no single number. + render(); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + const range = within(table).getByRole('row', { name: /^Accepted range/ }); + expect(within(range).getByText(RANGE_ONLY_FISCAL_NOTE.fiscalNoteSource!)).toBeInTheDocument(); + expect(within(range).getByRole('link', { name: 'Read source' })).toHaveAttribute( + 'href', + RANGE_ONLY_FISCAL_NOTE.fiscalNoteUrl + ); + expect(screen.getByText('Within fiscal-note range · Δ 5.3%')).toBeInTheDocument(); + }); + + test('given a fiscal note with a link and no estimate or range then a row still links it', () => { + render( + + ); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + const row = within(table).getByRole('row', { + name: new RegExp(`^${RANGE_ONLY_FISCAL_NOTE.fiscalNoteSource}`), + }); + expect(within(row).getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ + 'No estimate', + '—', + ]); + expect(within(row).getByRole('link', { name: 'Read source' })).toHaveAttribute( + 'href', + RANGE_ONLY_FISCAL_NOTE.fiscalNoteUrl + ); + }); + + test('given a fiscal note with a named source then its row is called by that source', () => { + // A proxy note (ct-sb78: OFA's score of another bill) must not read as the bill's own. + render( + + ); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + expect( + within(table).getByRole('row', { name: /^OFA fiscal note for sSB-1 Sec\. 6/ }) + ).toHaveTextContent('-$57.5M'); + expect(within(table).queryByText('Official fiscal note')).not.toBeInTheDocument(); + }); + test('given a bill outside the range then the chip says so', () => { render( { expect(claims[1].source).toBe('GBPI'); }); + test('given the fiscal note names its source then the claim carries that source', () => { + const claims = claimsFromBillValidation('ct-sb78', { + fiscalNoteEstimate: -57_500_000, + fiscalNoteSource: ' OFA fiscal note for sSB-1 Sec. 6 ', + peEstimate: -60_000_000, + }); + + expect(claims[0].source).toBe('OFA fiscal note for sSB-1 Sec. 6'); + }); + test('given no comparable estimates then no claims return', () => { expect(claimsFromBillValidation('x', { peEstimate: -5 })).toEqual([]); }); From 0a16f369908443c5331df2f6a43e2fa2264a1d47 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:20:03 -0400 Subject: [PATCH 12/40] Report: a shared link rebuilds the reform in the report's year, with its dates A report opened without its local stash rebuilt the reform for 2026 and dropped its dated values, so the overview showed the 2026 value against today's law, and Recompute, Edit in Build and Edit the reform ran a flat value from 2026. The rebuilt provisions now keep the policy's intervals, and their value and current law are those of the report's year. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/reportProvenance.ts | 41 ++-- app/src/pages/flagship/Report.page.tsx | 7 +- .../pages/flagship/sharedReportMocks.ts | 55 ++++++ .../libs/flagship/reportProvenance.test.ts | 65 +++++-- .../pages/flagship/ReportSharedLink.test.tsx | 176 ++++++++++++++++++ 5 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 app/src/tests/fixtures/pages/flagship/sharedReportMocks.ts create mode 100644 app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx diff --git a/app/src/libs/flagship/reportProvenance.ts b/app/src/libs/flagship/reportProvenance.ts index a9d498bbd..fd3ffb460 100644 --- a/app/src/libs/flagship/reportProvenance.ts +++ b/app/src/libs/flagship/reportProvenance.ts @@ -6,26 +6,33 @@ * else: a shared link, another device, or a cleared profile opens the * report with no provenance, and validation has nothing to match. The * reform policy on the PolicyEngine API carries the same parameter - * changes, so they are rebuilt from it here. + * changes, so they are rebuilt from it here, read in the report's year + * as the run that stashed them described them. */ import { CURRENT_YEAR } from '@/constants'; +import { provisionValueInYear } from '@/libs/draftReform'; import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; import type { Policy } from '@/types/ingredients/Policy'; -import type { Parameter } from '@/types/subIngredients/parameter'; +import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; import { getCurrentValue } from '@/utils/parameterValues'; -/** The value a parameter's intervals set for `year`; the first interval when none covers it. */ -export function valueForYear(parameter: Parameter, year: string = CURRENT_YEAR): unknown { - const covering = parameter.values.find( - (interval) => - String(interval.startDate).slice(0, 4) <= year && - String(interval.endDate ?? '9999').slice(0, 4) >= year +/** Current law for a parameter on 1 January of `year`; today's value before its first date. */ +function currentLawIn(values: Record | undefined, year: string): unknown { + if (!values) { + return undefined; + } + return ( + new ValueIntervalCollection(values).getValueAtDate(`${year}-01-01`) ?? getCurrentValue(values) ); - return (covering ?? parameter.values[0])?.value; } -/** Rebuilds report provisions from a policy's parameter changes. */ +/** + * Rebuilds report provisions from a policy's parameter changes. Each + * keeps every dated value the policy sets, so a recompute or an edit + * runs the same reform; its value and current law are those of the + * report's year. + */ export function provisionsFromPolicy( policy: Policy | null | undefined, parameters: Record | null | undefined, @@ -33,21 +40,29 @@ export function provisionsFromPolicy( ): RunReportProvision[] { return (policy?.parameters ?? []).map((parameter) => { const metadata = parameters?.[parameter.name]; + const baselineValue = currentLawIn(metadata?.values, year); + const intervals = parameter.values.length > 0 ? parameter.values : undefined; + const inYear = intervals + ? provisionValueInYear({ value: undefined, intervals }, Number(year)) + : null; return { path: parameter.name, breadcrumb: metadata ? formatLabelParts(getHierarchicalLabels(parameter.name, parameters!)) : parameter.name, unit: metadata?.unit ?? null, - baselineValue: getCurrentValue(metadata?.values), - value: valueForYear(parameter, year), + baselineValue, + // A year the policy does not reach keeps current law. + value: inYear ?? baselineValue, + ...(intervals && { intervals }), }; }); } /** * Provenance for a report opened without its local stash: the reform - * policy's changes as provisions, the report's own label as title. + * policy's changes as provisions in the report's year, the report's own + * label as title. */ export function provenanceFromPolicy( policy: Policy | null | undefined, diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 024c281a0..51dd70ad9 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -80,13 +80,16 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep 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. + // policy so a shared link validates like the original — read in the + // report's year, with every dated value the run had. const reformPolicy = policies.find((policy) => policy.id === simulations?.[1]?.policyId); // Without metadata the rebuilt provisions would show raw paths and no // baseline, so they count as still loading until it arrives. const meta = readReportMeta(userReportId) ?? - (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); + (parameters + ? provenanceFromPolicy(reformPolicy, parameters, report?.label, report?.year || undefined) + : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); // Stored provenance that can't be read: readReportMeta answers none for it. diff --git a/app/src/tests/fixtures/pages/flagship/sharedReportMocks.ts b/app/src/tests/fixtures/pages/flagship/sharedReportMocks.ts new file mode 100644 index 000000000..48304ac11 --- /dev/null +++ b/app/src/tests/fixtures/pages/flagship/sharedReportMocks.ts @@ -0,0 +1,55 @@ +import { + DEDUCTION_PARAMETERS, + EXEMPTION_PATH, +} from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + EXISTING_REPORT, + EXISTING_SIMULATIONS, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import type { Policy } from '@/types/ingredients/Policy'; +import type { Report } from '@/types/ingredients/Report'; + +/** + * A report opened from a shared link: no provenance stashed in this + * browser, so the page rebuilds the reform from its policy on the API. + */ + +/** A report year after the current one, where current law has moved on. */ +export const REPORT_YEAR_2028 = '2028'; + +/** The reform policy's id, as the reform simulation names it. */ +export const SCHEDULE_POLICY_ID = EXISTING_SIMULATIONS[1].policyId!; + +/** The personal exemption as metadata has it: 5,000 in 2026, 5,200 in 2028. */ +export const EXEMPTION_METADATA = { + [EXEMPTION_PATH]: DEDUCTION_PARAMETERS[EXEMPTION_PATH], +}; + +/** Current law for the exemption on 1 January 2028. */ +export const EXEMPTION_CURRENT_LAW_2028 = 5200; + +/** A phased reform: 6,000 for 2026 and 2027, then 8,000 from 2028 on. */ +export const EXEMPTION_SCHEDULE = [ + { startDate: '2026-01-01', endDate: '2027-12-31', value: 6000 }, + { startDate: '2028-01-01', endDate: '2100-12-31', value: 8000 }, +]; + +/** The schedule as the API's policy period map. */ +export const EXEMPTION_SCHEDULE_PERIODS = { + '2026-01-01.2027-12-31': 6000, + '2028-01-01.2100-12-31': 8000, +}; + +export const EXEMPTION_SCHEDULE_POLICY: Policy = { + id: SCHEDULE_POLICY_ID, + countryId: 'us', + label: 'Phased exemption', + parameters: [{ name: EXEMPTION_PATH, values: EXEMPTION_SCHEDULE }], +}; + +/** The 2028 report of that reform, nationwide. */ +export const SCHEDULE_REPORT_2028: Report = { + ...EXISTING_REPORT, + label: 'Phased exemption', + year: REPORT_YEAR_2028, +}; diff --git a/app/src/tests/unit/libs/flagship/reportProvenance.test.ts b/app/src/tests/unit/libs/flagship/reportProvenance.test.ts index 8b811c0ba..d165e42b8 100644 --- a/app/src/tests/unit/libs/flagship/reportProvenance.test.ts +++ b/app/src/tests/unit/libs/flagship/reportProvenance.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from 'vitest'; import { isApiReportId } from '@/hooks/useFlagshipReport'; +import { provenanceFromPolicy, provisionsFromPolicy } from '@/libs/flagship/reportProvenance'; +import { provisionPeriods } from '@/libs/flagship/runReport'; import { - provenanceFromPolicy, - provisionsFromPolicy, - valueForYear, -} from '@/libs/flagship/reportProvenance'; + EXEMPTION_METADATA, + EXEMPTION_SCHEDULE_PERIODS, + EXEMPTION_SCHEDULE_POLICY, + REPORT_YEAR_2028, +} from '@/tests/fixtures/pages/flagship/sharedReportMocks'; import type { Policy } from '@/types/ingredients/Policy'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; @@ -44,17 +47,6 @@ describe('isApiReportId', () => { }); }); -describe('valueForYear', () => { - test('given dated intervals then the one covering the year wins', () => { - expect(valueForYear(reformPolicy.parameters![0], '2026')).toBe(2500); - expect(valueForYear(reformPolicy.parameters![0], '2025')).toBe(2200); - }); - - test('given no covering interval then the first stands in', () => { - expect(valueForYear(reformPolicy.parameters![0], '2010')).toBe(2200); - }); -}); - describe('provisionsFromPolicy', () => { test('given a policy and metadata then provisions carry path, value, baseline, and unit', () => { const [provision] = provisionsFromPolicy(reformPolicy, metadataParameters, '2026'); @@ -68,6 +60,38 @@ describe('provisionsFromPolicy', () => { expect(provision.breadcrumb).not.toBe(CTC_BASE_PATH); }); + test('given dated values then the provision reads the one in the report year', () => { + expect(provisionsFromPolicy(reformPolicy, metadataParameters, '2025')[0].value).toBe(2200); + expect(provisionsFromPolicy(reformPolicy, metadataParameters, '2026')[0].value).toBe(2500); + }); + + test('given a later report year then current law is read in that year', () => { + const [provision] = provisionsFromPolicy( + EXEMPTION_SCHEDULE_POLICY, + EXEMPTION_METADATA, + REPORT_YEAR_2028 + ); + + expect(provision.value).toBe(8000); + expect(provision.baselineValue).toBe(5200); + }); + + test('given dated values then the provision keeps every one, as the report ran them', () => { + const [provision] = provisionsFromPolicy( + EXEMPTION_SCHEDULE_POLICY, + EXEMPTION_METADATA, + REPORT_YEAR_2028 + ); + + expect(provisionPeriods(provision)).toEqual(EXEMPTION_SCHEDULE_PERIODS); + }); + + test('given a year the policy does not reach then current law stands as the value', () => { + const [provision] = provisionsFromPolicy(reformPolicy, metadataParameters, '2010'); + + expect(provision.value).toBe(provision.baselineValue); + }); + test('given a path missing from metadata then the path itself is the breadcrumb', () => { const [provision] = provisionsFromPolicy(reformPolicy, {}, '2026'); @@ -87,6 +111,17 @@ describe('provenanceFromPolicy', () => { ); }); + test('given the report year then the provisions read that year', () => { + const meta = provenanceFromPolicy( + EXEMPTION_SCHEDULE_POLICY, + EXEMPTION_METADATA, + null, + REPORT_YEAR_2028 + ); + + expect(meta?.provisions[0].value).toBe(8000); + }); + test('given no policy yet then there is no provenance', () => { expect(provenanceFromPolicy(undefined, metadataParameters, 'x')).toBeNull(); }); diff --git a/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx new file mode 100644 index 000000000..905269332 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx @@ -0,0 +1,176 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; +import { provisionPeriods } from '@/libs/flagship/runReport'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + seedDeductionMetadata, + US_REGIONS, +} from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + EXISTING_REPORT_ID, + EXISTING_SIMULATIONS, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { + EXEMPTION_CURRENT_LAW_2028, + EXEMPTION_SCHEDULE, + EXEMPTION_SCHEDULE_PERIODS, + EXEMPTION_SCHEDULE_POLICY, + REPORT_YEAR_2028, + SCHEDULE_REPORT_2028, +} from '@/tests/fixtures/pages/flagship/sharedReportMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; + +const mockUseFlagshipReport = vi.fn(); +const mockCalcStatus = vi.fn(); +const mockRun = vi.fn(); +const mockFindMine = vi.fn(); +const mockCreate = vi.fn(); + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => mockUseFlagshipReport(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: mockRun, isRunning: false, error: null }), +})); + +vi.mock('@/api/reformStore', async (importOriginal) => ({ + ...(await importOriginal()), + getReformStore: () => ({ findMine: mockFindMine, create: mockCreate }), +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +const COMPLETE_STATUS = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), +}; + +const ERROR_STATUS = { + status: 'error', + isComplete: false, + isError: true, + error: { message: 'Simulation failed' }, + result: null, +}; + +/** The 2028 report of the phased reform, as a shared link loads it. */ +function sharedReport(overrides: Partial = {}): FlagshipReportData { + return { + report: SCHEDULE_REPORT_2028, + simulations: EXISTING_SIMULATIONS, + policies: [EXEMPTION_SCHEDULE_POLICY], + isLoading: false, + error: null, + notFound: false, + failedPolicyIds: [], + retry: vi.fn(), + ...overrides, + }; +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const page = () => ( + + + + ); + const result = render(page()); + return { ...result, rerenderPage: () => result.rerender(page()) }; +} + +const openPanel = (user: ReturnType) => + user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + +describe('FlagshipReportPage from a shared link', () => { + beforeEach(() => { + vi.clearAllMocks(); + // No provenance stashed: this browser did not run the report. + localStorage.clear(); + clearDraftReform(); + store.dispatch(clearMetadata()); + seedDeductionMetadata({ regions: US_REGIONS }); + mockFindMine.mockResolvedValue([]); + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'Phased exemption (adjusted)' }); + mockCalcStatus.mockReturnValue(COMPLETE_STATUS); + mockUseFlagshipReport.mockReturnValue(sharedReport()); + }); + + test('given a later report year then the panel shows the value that year ran, against that year’s law', async () => { + // Given + const user = userEvent.setup(); + renderPage(); + + // When + await openPanel(user); + + // Then + expect(screen.getByLabelText(/new value for/i)).toHaveValue(8000); + expect( + screen.getByText(`Baseline: $${EXEMPTION_CURRENT_LAW_2028.toLocaleString('en-US')}`) + ).toBeInTheDocument(); + }); + + test('given only the population changes then recompute runs the same dated reform', async () => { + // Given + const user = userEvent.setup(); + renderPage(); + await openPanel(user); + + // When + await user.click(screen.getByRole('button', { name: 'Population: Nationwide' })); + await user.click(await screen.findByRole('option', { name: /California/ })); + await user.click(screen.getByRole('button', { name: /recompute/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + const [, , provisions, , year] = mockRun.mock.calls[0]; + expect(year).toBe(Number(REPORT_YEAR_2028)); + expect(provisionPeriods(provisions[0])).toEqual(EXEMPTION_SCHEDULE_PERIODS); + }); + + test('given a failed run then Edit the reform puts the dated reform back in the draft', async () => { + // Given + const user = userEvent.setup(); + mockCalcStatus.mockReturnValue(ERROR_STATUS); + renderPage(); + + // When + await user.click(screen.getByRole('button', { name: 'Edit the reform' })); + + // Then + const draft = getDraftReform(); + expect(draft?.year).toBe(Number(REPORT_YEAR_2028)); + expect(draft?.provisions[0].intervals).toEqual(EXEMPTION_SCHEDULE); + clearDraftReform(); + }); +}); From 218b628f19c69ac8d2ddc08cfe3f8da0dae50819 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:20:28 -0400 Subject: [PATCH 13/40] Report: a group with no baseline rate reads n/a, not NaN% on the All bar reform / baseline - 1 is NaN (or Infinity) for a group with no one in poverty before. Recharts drew no bar for it, so the value labels, which go by the index of the bars drawn, moved onto the next group: 'NaN%' sat on All and All's own value was lost. Such a group now draws a bar of zero that reads 'n/a', and its hover and the title say the change in points. Co-Authored-By: Claude Opus 5.5 --- .../charts/DeepPovertyImpactByAgeSubPage.tsx | 67 +++++--------- .../DeepPovertyImpactByGenderSubPage.tsx | 67 ++++++-------- .../report/charts/InequalityImpactSubPage.tsx | 44 ++++----- .../charts/PovertyImpactByAgeSubPage.tsx | 66 +++++--------- .../charts/PovertyImpactByGenderSubPage.tsx | 67 ++++++-------- .../charts/PovertyImpactByRaceSubPage.tsx | 56 +++++------- .../flagship/report/charts/relativeChange.ts | 90 +++++++++++++++++++ .../report/charts/reportChartMocks.ts | 37 ++++++++ .../report/charts/ReportBarCharts.test.tsx | 43 +++++++++ .../report/charts/relativeChange.test.ts | 72 +++++++++++++++ 10 files changed, 387 insertions(+), 222 deletions(-) create mode 100644 app/src/components/flagship/report/charts/relativeChange.ts create mode 100644 app/src/tests/unit/components/flagship/report/charts/relativeChange.test.ts diff --git a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx index de305eafa..6778fcf3f 100644 --- a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx +++ b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx @@ -21,6 +21,12 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -31,9 +37,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByAgeCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ @@ -61,19 +66,6 @@ export default function DeepPovertyImpactByAgeSubPage({ // Extract data const deepPovertyImpact = output.poverty.deep_poverty; - // Calculate changes for each age group - const childPovertyChange = deepPovertyImpact.child.reform / deepPovertyImpact.child.baseline - 1; - const adultPovertyChange = deepPovertyImpact.adult.reform / deepPovertyImpact.adult.baseline - 1; - const seniorPovertyChange = - deepPovertyImpact.senior.reform / deepPovertyImpact.senior.baseline - 1; - const totalPovertyChange = deepPovertyImpact.all.reform / deepPovertyImpact.all.baseline - 1; - - const povertyChanges = [ - childPovertyChange, - adultPovertyChange, - seniorPovertyChange, - totalPovertyChange, - ]; const povertyLabels = ['Children', 'Working-age adults', 'Seniors', 'All']; const labelToKey: Record = { Children: 'child', @@ -81,6 +73,11 @@ export default function DeepPovertyImpactByAgeSubPage({ Seniors: 'senior', All: 'all', }; + // Each age group's change; none for a group with no one in deep poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = deepPovertyImpact[labelToKey[label]]; + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -91,45 +88,27 @@ export default function DeepPovertyImpactByAgeSubPage({ // Generate hover message const hoverMessage = (x: string) => { const obj = `the percentage of ${x === 'All' ? 'people' : x.toLowerCase()} in deep poverty`; - const baseline = deepPovertyImpact[labelToKey[x]].baseline; - const reform = deepPovertyImpact[labelToKey[x]].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = deepPovertyImpact[labelToKey[x]]; + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = deepPovertyImpact.all.baseline; - const reform = deepPovertyImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the deep poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = deepPovertyImpact.all; + return rateChangeTitle( + 'the deep poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/DeepPovertyImpactByGenderSubPage.tsx b/app/src/components/flagship/report/charts/DeepPovertyImpactByGenderSubPage.tsx index 5849d5f80..9bf2c8347 100644 --- a/app/src/components/flagship/report/charts/DeepPovertyImpactByGenderSubPage.tsx +++ b/app/src/components/flagship/report/charts/DeepPovertyImpactByGenderSubPage.tsx @@ -21,6 +21,12 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -31,9 +37,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByGenderCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ @@ -65,18 +70,21 @@ export default function DeepPovertyImpactByGenderSubPage({ }; const allImpact = output.poverty.deep_poverty; - // Calculate changes for each gender - const malePovertyChange = genderImpact.male.reform / genderImpact.male.baseline - 1; - const femalePovertyChange = genderImpact.female.reform / genderImpact.female.baseline - 1; - const totalPovertyChange = allImpact.all.reform / allImpact.all.baseline - 1; - - const povertyChanges = [malePovertyChange, femalePovertyChange, totalPovertyChange]; const povertyLabels = ['Male', 'Female', 'All']; const labelToKey: Record = { Male: 'male', Female: 'female', All: 'all', }; + const rateOf = (label: string) => { + const key = labelToKey[label]; + return key === 'all' ? allImpact.all : genderImpact[key]; + }; + // Each group's change; none for a group with no one in deep poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = rateOf(label); + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,48 +98,27 @@ export default function DeepPovertyImpactByGenderSubPage({ const obj = `the percentage of ${ x === 'All' ? 'people' : genderMap[x.toLowerCase()] } in deep poverty`; - const key = labelToKey[x]; - const baseline = - x === 'All' ? allImpact.all.baseline : genderImpact[key as 'male' | 'female'].baseline; - const reform = - x === 'All' ? allImpact.all.reform : genderImpact[key as 'male' | 'female'].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = rateOf(x); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the deep poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the deep poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/InequalityImpactSubPage.tsx b/app/src/components/flagship/report/charts/InequalityImpactSubPage.tsx index 2bfa49af5..8966080a0 100644 --- a/app/src/components/flagship/report/charts/InequalityImpactSubPage.tsx +++ b/app/src/components/flagship/report/charts/InequalityImpactSubPage.tsx @@ -20,6 +20,11 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -30,7 +35,6 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getInequalityCsvRows } from '@/pages/report-output/inequality-impact/inequalityChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; import { formatPercent, precision } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; @@ -65,14 +69,19 @@ export default function InequalityImpactSubPage({ const top1Impact = output.inequality.top_1_pct_share; const labels = ['Gini index', 'Top 10% share', 'Top 1% share']; - const metricChanges = [ - giniImpact.reform / giniImpact.baseline - 1, - top10Impact.reform / top10Impact.baseline - 1, - top1Impact.reform / top1Impact.baseline - 1, - ]; + // Each measure's change; none for a measure that is zero before. + const metricChanges = [giniImpact, top10Impact, top1Impact].map(({ baseline, reform }) => + relativeChange(reform, baseline) + ); // Calculate precision for display - const yvaluePrecision = Math.max(1, precision(metricChanges, 100)); + const yvaluePrecision = Math.max( + 1, + precision( + metricChanges.filter((change): change is number => change !== null), + 100 + ) + ); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -103,22 +112,16 @@ export default function InequalityImpactSubPage({ formatter = formatPer; } - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter, - }); + return rateChangeMessage(obj, baseline, reform, countryId, formatter); }; // Generate chart title const getChartTitle = () => { - const signTerm = - metricChanges[0] > 0 && metricChanges[1] > 0 && metricChanges[2] > 0 - ? 'increase' - : metricChanges[0] < 0 && metricChanges[1] < 0 && metricChanges[2] < 0 - ? 'decrease' - : 'have an ambiguous effect on'; + const signTerm = metricChanges.every((change) => change !== null && change > 0) + ? 'increase' + : metricChanges.every((change) => change !== null && change < 0) + ? 'decrease' + : 'have an ambiguous effect on'; const region = regionName(metadata); const regionPhrase = region ? ` in ${region}` : ''; return `This reform would ${signTerm} income inequality${regionPhrase}`; @@ -127,8 +130,7 @@ export default function InequalityImpactSubPage({ // Recharts data const chartData = labels.map((label, i) => ({ name: label, - value: metricChanges[i], - label: (metricChanges[i] > 0 ? '+' : '') + formatPer(metricChanges[i]), + ...relativeBar(metricChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/PovertyImpactByAgeSubPage.tsx b/app/src/components/flagship/report/charts/PovertyImpactByAgeSubPage.tsx index 23d7c4dcd..8147185cb 100644 --- a/app/src/components/flagship/report/charts/PovertyImpactByAgeSubPage.tsx +++ b/app/src/components/flagship/report/charts/PovertyImpactByAgeSubPage.tsx @@ -21,6 +21,12 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -31,9 +37,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByAgeCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ @@ -61,18 +66,6 @@ export default function PovertyImpactByAgeSubPage({ // Extract data const povertyImpact = output.poverty.poverty; - // Calculate changes for each age group - const childPovertyChange = povertyImpact.child.reform / povertyImpact.child.baseline - 1; - const adultPovertyChange = povertyImpact.adult.reform / povertyImpact.adult.baseline - 1; - const seniorPovertyChange = povertyImpact.senior.reform / povertyImpact.senior.baseline - 1; - const totalPovertyChange = povertyImpact.all.reform / povertyImpact.all.baseline - 1; - - const povertyChanges = [ - childPovertyChange, - adultPovertyChange, - seniorPovertyChange, - totalPovertyChange, - ]; const povertyLabels = ['Children', 'Working-age adults', 'Seniors', 'All']; const labelToKey: Record = { Children: 'child', @@ -80,6 +73,11 @@ export default function PovertyImpactByAgeSubPage({ Seniors: 'senior', All: 'all', }; + // Each age group's change; none for a group with no one in poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = povertyImpact[labelToKey[label]]; + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,45 +88,27 @@ export default function PovertyImpactByAgeSubPage({ // Generate hover message const hoverMessage = (x: string) => { const obj = `the percentage of ${x === 'All' ? 'people' : x.toLowerCase()} in poverty`; - const baseline = povertyImpact[labelToKey[x]].baseline; - const reform = povertyImpact[labelToKey[x]].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = povertyImpact[labelToKey[x]]; + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = povertyImpact.all.baseline; - const reform = povertyImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = povertyImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/PovertyImpactByGenderSubPage.tsx b/app/src/components/flagship/report/charts/PovertyImpactByGenderSubPage.tsx index 325287518..940f326ef 100644 --- a/app/src/components/flagship/report/charts/PovertyImpactByGenderSubPage.tsx +++ b/app/src/components/flagship/report/charts/PovertyImpactByGenderSubPage.tsx @@ -21,6 +21,12 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -31,9 +37,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByGenderCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ @@ -65,18 +70,21 @@ export default function PovertyImpactByGenderSubPage({ }; const allImpact = output.poverty.poverty; - // Calculate changes for each gender - const malePovertyChange = genderImpact.male.reform / genderImpact.male.baseline - 1; - const femalePovertyChange = genderImpact.female.reform / genderImpact.female.baseline - 1; - const totalPovertyChange = allImpact.all.reform / allImpact.all.baseline - 1; - - const povertyChanges = [malePovertyChange, femalePovertyChange, totalPovertyChange]; const povertyLabels = ['Male', 'Female', 'All']; const labelToKey: Record = { Male: 'male', Female: 'female', All: 'all', }; + const rateOf = (label: string) => { + const key = labelToKey[label]; + return key === 'all' ? allImpact.all : genderImpact[key]; + }; + // Each group's change; none for a group with no one in poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = rateOf(label); + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,48 +98,27 @@ export default function PovertyImpactByGenderSubPage({ const obj = `the percentage of ${ x === 'All' ? 'people' : genderMap[x.toLowerCase()] } in poverty`; - const key = labelToKey[x]; - const baseline = - x === 'All' ? allImpact.all.baseline : genderImpact[key as 'male' | 'female'].baseline; - const reform = - x === 'All' ? allImpact.all.reform : genderImpact[key as 'male' | 'female'].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = rateOf(x); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/PovertyImpactByRaceSubPage.tsx b/app/src/components/flagship/report/charts/PovertyImpactByRaceSubPage.tsx index 23fadf934..e745600cf 100644 --- a/app/src/components/flagship/report/charts/PovertyImpactByRaceSubPage.tsx +++ b/app/src/components/flagship/report/charts/PovertyImpactByRaceSubPage.tsx @@ -20,6 +20,12 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -30,9 +36,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByRaceCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ @@ -64,11 +69,11 @@ export default function PovertyImpactByRaceSubPage({ // Get all race keys dynamically const raceKeys = Object.keys(raceImpact).filter((key) => key !== 'all'); - const raceChanges = raceKeys.map((key) => { - const data = raceImpact[key]; - return data.reform / data.baseline - 1; - }); - const totalPovertyChange = allImpact.all.reform / allImpact.all.baseline - 1; + // Each group's change; none for a group with no one in poverty before. + const raceChanges = raceKeys.map((key) => + relativeChange(raceImpact[key].reform, raceImpact[key].baseline) + ); + const totalPovertyChange = relativeChange(allImpact.all.reform, allImpact.all.baseline); const povertyChanges = [...raceChanges, totalPovertyChange]; const povertyLabels = [ @@ -92,43 +97,26 @@ export default function PovertyImpactByRaceSubPage({ const key = labelToKey[x]; const baseline = x === 'All' ? allImpact.all.baseline : raceImpact[key].baseline; const reform = x === 'All' ? allImpact.all.reform : raceImpact[key].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); diff --git a/app/src/components/flagship/report/charts/relativeChange.ts b/app/src/components/flagship/report/charts/relativeChange.ts new file mode 100644 index 000000000..60dc901f2 --- /dev/null +++ b/app/src/components/flagship/report/charts/relativeChange.ts @@ -0,0 +1,90 @@ +import type { CountryId } from '@/libs/countries'; +import { absoluteChangeMessage, relativeChangeMessage } from '@/utils/chartMessages'; +import { formatNumber, formatPercent } from '@/utils/formatters'; + +/** What a bar reads when its group has no baseline rate to compare with. */ +export const NO_RELATIVE_CHANGE_LABEL = 'n/a'; + +/** + * A rate's change relative to its baseline, reform / baseline − 1; null + * when the baseline is zero, so there is no ratio (a small region can + * have no seniors in deep poverty, for example). + */ +export function relativeChange(reform: number, baseline: number): number | null { + const change = reform / baseline - 1; + return Number.isFinite(change) ? change : null; +} + +/** + * A bar of relative change: its value and its label. A group with no + * ratio draws a bar of zero that reads "n/a". Recharts draws no bar for + * a value that is not a number, and the value labels go by the index of + * the bars it draws, so a missing bar would move every later label onto + * the next group. + */ +export function relativeBar( + change: number | null, + format: (value: number) => string +): { value: number; label: string } { + if (change === null) { + return { value: 0, label: NO_RELATIVE_CHANGE_LABEL }; + } + return { value: change, label: (change > 0 ? '+' : '') + format(change) }; +} + +/** + * The hover message for a rate's change, "This reform would decrease … + * by 4.0% from 2.5% to 2.4%". With no baseline rate it gives the change + * in percentage points instead, or says there is none. + */ +export function rateChangeMessage( + objectTerm: string, + baseline: number, + reform: number, + countryId: CountryId, + format: (value: number) => string +): string { + const change = relativeChange(reform, baseline); + if (change === null) { + const fromTo = `from ${format(baseline)} to ${format(reform)}`; + return absoluteChangeMessage( + 'This reform', + objectTerm, + reform - baseline, + 0.001, + (points) => `${(points * 100).toFixed(1)}pp, ${fromTo}` + ); + } + return relativeChangeMessage('This reform', objectTerm, change, 0.001, countryId, { + baseline, + reform, + formatter: format, + }); +} + +/** + * A chart title for a rate's change, "This reform would decrease the + * poverty rate in Maryland by 4.0% (0.1pp)". From a rate of zero there + * is no relative change, so only the points show. + */ +export function rateChangeTitle( + objectTerm: string, + baseline: number, + reform: number, + countryId: CountryId, + regionPhrase: string +): string { + const absTerm = formatNumber(Math.round(Math.abs(reform - baseline) * 1000) / 10, countryId, { + maximumFractionDigits: 2, + }); + if (absTerm === '0') { + return `This reform would have no effect on ${objectTerm}${regionPhrase}`; + } + const change = relativeChange(reform, baseline); + const term = + change === null + ? `${absTerm}pp` + : `${formatPercent(Math.abs(change), countryId, { maximumFractionDigits: 1 })} (${absTerm}pp)`; + const signTerm = reform > baseline ? 'increase' : 'decrease'; + return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term}`; +} diff --git a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts index aaabc0750..b5e5bab03 100644 --- a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts @@ -216,3 +216,40 @@ export function withPovertyByAgeChanges( }, } as SocietyWideReportOutput; } + +type Rate = { baseline: number; reform: number }; + +/** A report's output with its deep poverty rates by age swapped for the given ones. */ +export function withDeepPovertyByAge( + output: SocietyWideReportOutput, + rates: { child: Rate; adult: Rate; senior: Rate; all: Rate } +): SocietyWideReportOutput { + return { + ...output, + poverty: { ...output.poverty, deep_poverty: rates }, + } as SocietyWideReportOutput; +} + +/** + * Deep poverty by age in a small region, as report 4302 (NY-04, 2026) + * returned it for seniors: none in deep poverty, before or after. + */ +export const NO_SENIORS_IN_DEEP_POVERTY = { + child: { baseline: 0.04, reform: 0.038 }, + adult: { baseline: 0.03, reform: 0.029 }, + senior: { baseline: 0, reform: 0 }, + all: { baseline: 0.025, reform: 0.024 }, +}; + +/** A report's output with one race's poverty rate swapped for the given one. */ +export function withRacePoverty( + output: SocietyWideReportOutput, + race: string, + rate: Rate +): SocietyWideReportOutput { + const byRace = (output as any).poverty_by_race; + return { + ...output, + poverty_by_race: { ...byRace, poverty: { ...byRace.poverty, [race]: rate } }, + } as SocietyWideReportOutput; +} diff --git a/app/src/tests/unit/components/flagship/report/charts/ReportBarCharts.test.tsx b/app/src/tests/unit/components/flagship/report/charts/ReportBarCharts.test.tsx index cbe7e49ae..207f130ca 100644 --- a/app/src/tests/unit/components/flagship/report/charts/ReportBarCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/charts/ReportBarCharts.test.tsx @@ -7,9 +7,12 @@ import InequalityImpactSubPage from '@/components/flagship/report/charts/Inequal import PovertyImpactByAgeSubPage from '@/components/flagship/report/charts/PovertyImpactByAgeSubPage'; import PovertyImpactByRaceSubPage from '@/components/flagship/report/charts/PovertyImpactByRaceSubPage'; import { + NO_SENIORS_IN_DEEP_POVERTY, REPORT_4332_OUTPUT, + withDeepPovertyByAge, withInequalityChanges, withPovertyByAgeChanges, + withRacePoverty, } from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks'; const size = vi.hoisted(() => ({ width: 700, height: 260 })); @@ -156,6 +159,46 @@ describe('report bar charts', () => { expectLabelsInsidePlot(container); }); + test('given a group with no baseline rate then it reads n/a and every other bar keeps its label', async () => { + // Given: no seniors in deep poverty, before or after, as in report 4302. + const output = withDeepPovertyByAge(REPORT_4332_OUTPUT, NO_SENIORS_IN_DEEP_POVERTY); + + // When + const { container } = render( + + ); + + // Then: one label a bar, in order, with All's own value on All. + await screen.findByText('-3.3%', undefined, LABELS_SHOWN); + const labels = valueLabels(container); + expect(labels.map((label) => label.text)).toEqual(['-5.0%', '-3.3%', 'n/a', '-4.0%']); + expect(labels.map((label) => label.x)).toEqual( + [...labels.map((label) => label.x)].sort((a, b) => a - b) + ); + const ticks = Array.from( + container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value') + ); + const allTick = ticks.find((tick) => tick.textContent === 'All'); + expect(Number(allTick?.getAttribute('x'))).toBeCloseTo(labels[3].x, 0); + }); + + test('given a race with no baseline poverty that the reform raises then its bar reads n/a', async () => { + // Given: no one of another race in poverty before the reform, some after. + size.width = 340; + size.height = 170; + const output = withRacePoverty(REPORT_4332_OUTPUT, 'other', { baseline: 0, reform: 0.01 }); + + // When + const { container } = render(); + + // Then + await screen.findByText('-0.8%', undefined, LABELS_SHOWN); + const texts = valueLabels(container).map((label) => label.text); + expect(texts).toHaveLength(5); + expect(texts[1]).toBe('n/a'); + expect(texts.join(' ')).not.toMatch(/NaN|∞/); + }); + test('given a short chart then the value axis title shortens to fit the plot', () => { size.width = 340; size.height = 150; diff --git a/app/src/tests/unit/components/flagship/report/charts/relativeChange.test.ts b/app/src/tests/unit/components/flagship/report/charts/relativeChange.test.ts new file mode 100644 index 000000000..c0251705b --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/charts/relativeChange.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; +import { + NO_RELATIVE_CHANGE_LABEL, + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; + +const percent = (value: number) => `${(value * 100).toFixed(1)}%`; + +describe('relativeChange', () => { + test('given a baseline rate then the change is relative to it', () => { + expect(relativeChange(0.024, 0.025)).toBeCloseTo(-0.04); + }); + + test('given a zero baseline then there is no ratio', () => { + expect(relativeChange(0, 0)).toBeNull(); + expect(relativeChange(0.01, 0)).toBeNull(); + }); +}); + +describe('relativeBar', () => { + test('given a change then the label is signed', () => { + expect(relativeBar(0.021, percent)).toEqual({ value: 0.021, label: '+2.1%' }); + expect(relativeBar(-0.04, percent)).toEqual({ value: -0.04, label: '-4.0%' }); + }); + + test('given no ratio then the bar is zero and reads n/a', () => { + expect(relativeBar(null, percent)).toEqual({ value: 0, label: NO_RELATIVE_CHANGE_LABEL }); + }); +}); + +describe('rateChangeMessage', () => { + test('given a baseline rate then the change reads as relative', () => { + expect(rateChangeMessage('the rate', 0.025, 0.024, 'us', percent).replace(/\n/g, ' ')).toBe( + 'This reform would decrease the rate by 4.0% from 2.5% to 2.4%' + ); + }); + + test('given no one before or after then it has no effect', () => { + expect(rateChangeMessage('the rate', 0, 0, 'us', percent)).toBe( + 'This reform would have no effect on the rate' + ); + }); + + test('given a rise from zero then it reads in percentage points', () => { + expect(rateChangeMessage('the rate', 0, 0.01, 'us', percent).replace(/\n/g, ' ')).toBe( + 'This reform would increase the rate by 1.0pp, from 0.0% to 1.0%' + ); + }); +}); + +describe('rateChangeTitle', () => { + test('given a baseline rate then the title gives the relative change and the points', () => { + expect(rateChangeTitle('the poverty rate', 0.14, 0.133, 'us', ' in Maryland')).toBe( + 'This reform would decrease the poverty rate in Maryland by 5% (0.7pp)' + ); + }); + + test('given a rise from zero then the title gives the points alone', () => { + expect(rateChangeTitle('the poverty rate', 0, 0.01, 'us', '')).toBe( + 'This reform would increase the poverty rate by 1pp' + ); + }); + + test('given no change then it has no effect', () => { + expect(rateChangeTitle('the poverty rate', 0, 0, 'us', '')).toBe( + 'This reform would have no effect on the poverty rate' + ); + }); +}); From 5dcbab879a3dc19ee75d44e96c2dd8ceca7dd4ec Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:21:20 -0400 Subject: [PATCH 14/40] Report: the adjust panel shows a shared link's reform once it loads On a shared link the results can arrive before the reform policy, which waits for the parameter metadata. The page rendered with no provisions, and the adjust panel kept its first, empty values and name: Recompute was enabled and ran a reform with no values. The page now waits for the reform policy (unless it or the metadata failed), and the panel starts over whenever the reform it adjusts changes. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportAdjustPanel.tsx | 18 +++++++- app/src/pages/flagship/Report.page.tsx | 22 ++++++---- .../flagship/ReportAdjustPanel.test.tsx | 20 +++++++++ .../pages/flagship/ReportSharedLink.test.tsx | 44 +++++++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 7fa1ac21f..696725ea9 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -62,8 +62,24 @@ function reformMatches(reform: Reform, provisions: { path: string; value: any }[ * reforms this browser saved — an exact match is reused, anything new * is saved as its own reform — then runs the pipeline and lands on the * new report, linked to that reform. Collapses to a slim edge tab. + * + * It starts over from the reform whenever the reform changes: a shared + * link's provisions can arrive after the results, and the values and + * name it edits must be theirs, not those of the empty reform before. */ -export default function ReportAdjustPanel({ +export default function ReportAdjustPanel(props: ReportAdjustPanelProps) { + const reform = JSON.stringify([ + props.title, + props.provisions.map((provision) => [ + provision.path, + provision.value, + provision.intervals ?? null, + ]), + ]); + return ; +} + +function AdjustPanel({ title, sourceNote, provisions, diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 51dd70ad9..0c6ebd1a7 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -82,20 +82,27 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // Provenance: the local stash from the run, else rebuilt from the reform // policy so a shared link validates like the original — read in the // report's year, with every dated value the run had. - const reformPolicy = policies.find((policy) => policy.id === simulations?.[1]?.policyId); - // Without metadata the rebuilt provisions would show raw paths and no - // baseline, so they count as still loading until it arrives. + const reformPolicyId = simulations?.[1]?.policyId; + const reformPolicy = policies.find((policy) => policy.id === reformPolicyId); const meta = readReportMeta(userReportId) ?? - (parameters - ? provenanceFromPolicy(reformPolicy, parameters, report?.label, report?.year || undefined) - : null); + provenanceFromPolicy(reformPolicy, parameters, report?.label, report?.year || undefined); const calcStatus = useCalculationStatus(report?.id || '', 'report'); // Stored provenance that can't be read: readReportMeta answers none for it. const unreadableMeta = useUnreadableReportMeta(userReportId); // The reform's provisions are rebuilt from metadata; without it they cannot show. const metadata = useFlagshipMetadataStatus(); + // Rebuilt, the provisions are on their way until the reform policy loads + // (it waits for the parameter metadata, so names and current law come + // with it): the report waits for them rather than show an empty reform + // to adjust. A policy or metadata that failed lets it show without them. + const awaitingProvisions = + !meta && + isApiReportId(userReportId) && + !!reformPolicyId && + !failedPolicyIds.includes(reformPolicyId) && + metadata.status !== 'error'; // The run handed its draft over to this report; Build starts fresh. useEffect(() => { @@ -142,7 +149,6 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const output = calcStatus.isComplete ? (calcStatus.result as SocietyWideOutput) : null; - const reformPolicyId = simulations?.[1]?.policyId; const baselinePolicyId = simulations?.[0]?.policyId; const baselinePolicy = policies.find((policy) => policy.id === baselinePolicyId); // A baseline policy that failed to load is unknown, not current law. @@ -193,7 +199,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } - if (!output || isLoading || (!meta && !!reformPolicy)) { + if (!output || isLoading || awaitingProvisions) { if (unresolvable) { return layout( { expect(screen.getByRole('button', { name: /recompute/i })).toBeDisabled(); }); + test('given the provisions arrive after the panel then it shows them, with nothing to recompute', async () => { + // Given: a shared link renders the results before the reform policy loads. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const panel = (title: string, provisions: typeof PROVISIONS) => ( + + + + ); + const { rerender } = render(panel('Impact report', [])); + + // When + rerender(panel('CTC expansion', [PROVISIONS[0]])); + await userEvent.setup().click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // Then + expect(screen.getByLabelText(/new value for maximum credit per child/i)).toHaveValue(2500); + expect(screen.getByRole('textbox', { name: 'Reform name' })).toHaveValue('CTC expansion'); + expect(screen.getByRole('button', { name: /recompute/i })).toBeDisabled(); + }); + test('given Edit in Build then the reform as adjusted opens there as the draft', async () => { clearDraftReform(); const user = userEvent.setup(); diff --git a/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx index 905269332..761e7fa29 100644 --- a/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx @@ -173,4 +173,48 @@ describe('FlagshipReportPage from a shared link', () => { expect(draft?.provisions[0].intervals).toEqual(EXEMPTION_SCHEDULE); clearDraftReform(); }); + + test('given the reform policy is still loading then the page waits for it', () => { + // Given: the results are in; the policy waits on the parameter metadata. + mockUseFlagshipReport.mockReturnValue(sharedReport({ policies: [] })); + + // When + renderPage(); + + // Then + expect(screen.getByRole('status', { name: 'Loading report' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /open adjust parameters/i }) + ).not.toBeInTheDocument(); + }); + + test('given the reform policy arrives then the panel shows it, with nothing to recompute', async () => { + // Given + const user = userEvent.setup(); + mockUseFlagshipReport.mockReturnValue(sharedReport({ policies: [] })); + const { rerenderPage } = renderPage(); + + // When + mockUseFlagshipReport.mockReturnValue(sharedReport()); + rerenderPage(); + await openPanel(user); + + // Then + expect(screen.getByLabelText(/new value for/i)).toHaveValue(8000); + expect(screen.getByRole('button', { name: /recompute/i })).toBeDisabled(); + }); + + test('given the reform policy failed to load then the report shows without it', () => { + // Given + mockUseFlagshipReport.mockReturnValue( + sharedReport({ policies: [], failedPolicyIds: [EXEMPTION_SCHEDULE_POLICY.id!] }) + ); + + // When + renderPage(); + + // Then + expect(screen.queryByRole('status', { name: 'Loading report' })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Validation' })).toBeInTheDocument(); + }); }); From f721182ce424b2ed26d56dbc23cc5de0fbb52c4a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:21:21 -0400 Subject: [PATCH 15/40] Report: a bill whose stored parameters contradict its write-up is not re-run UT SB60's tracker row scores 0.045, the law before the bill, under a Policy changes table of 4.5% to 4.45%, so Load full results showed an $85.0m revenue gain for a rate cut. The bill report now compares the values a run would score with the write-up's 'after' values. When they disagree, the page keeps the tracker's stored estimate and says why the full results are not available. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/billProvisions.ts | 74 ++++++++++++++- app/src/pages/flagship/BillReport.page.tsx | 37 ++++++-- .../unit/libs/flagship/billProvisions.test.ts | 91 ++++++++++++++++++- .../unit/pages/flagship/BillReport.test.tsx | 39 ++++++++ 4 files changed, 230 insertions(+), 11 deletions(-) diff --git a/app/src/libs/flagship/billProvisions.ts b/app/src/libs/flagship/billProvisions.ts index 6ad2d081d..147c7caa0 100644 --- a/app/src/libs/flagship/billProvisions.ts +++ b/app/src/libs/flagship/billProvisions.ts @@ -1,4 +1,4 @@ -import { intervalValueAt, type TrackedBillProvision } from '@/api/billFeed'; +import { intervalValueAt, type BillChange, type TrackedBillProvision } from '@/api/billFeed'; import { isAlreadyCurrentLaw, lawValuesFrom } from '@/libs/flagship/billMetrics'; import type { RunReportProvision } from '@/libs/flagship/runReport'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; @@ -89,3 +89,75 @@ export function billAlreadyCurrentLaw( }) ); } + +/** + * A write-up value that is one plain number — "4.45%", "$2,000", "-0.5" — + * with how far its rounding lets the true value lie: half its last digit. + * Anything else (words, ranges, several values) reads as nothing. + */ +function writeUpNumber(text: string): { value: number; tolerance: number } | undefined { + const match = text + .trim() + .match(/^([-\u2212+]?)\s*\$?\s*(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?\s*%?$/); + if (!match) { + return undefined; + } + const [, sign, whole, decimals = ''] = match; + const magnitude = Number(`${whole.replace(/,/g, '')}.${decimals || '0'}`); + return { + value: sign === '-' || sign === '\u2212' ? -magnitude : magnitude, + tolerance: 0.5 * 10 ** -decimals.length, + }; +} + +/** Whether a change's parameter, which can hold "{filing_status}", names this path. */ +function changeNamesPath(parameter: string, path: string): boolean { + const pattern = parameter + .split(/\{[^}]+\}/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[^.]+'); + return new RegExp(`^${pattern}$`).test(path); +} + +/** + * Whether the values a bill's run would score disagree with the tracker's + * write-up of its changes — the "after" it shows in the Policy changes + * table. A change is checked against the provisions it names, or against + * a bill's only provision when the write-up has one change; one that + * cannot be paired, or whose value is not one number, is not checked. A + * written value agrees with any value the bill sets, as written or as a + * percent of it ("4.45%" is 0.0445), within the write-up's rounding. + * + * When they disagree the stored parameters do not describe the bill as + * written up (a tracker row with its reform and baseline swapped scores + * the law before the bill), and a run would score some other change. + */ +export function billScoresOtherThanWriteUp( + provisions: Array>, + changes: BillChange[] | undefined +): boolean { + if (!changes || changes.length === 0) { + return false; + } + return changes.some((change) => { + const written = writeUpNumber(change.after); + if (!written) { + return false; + } + const described = change.parameter + ? provisions.filter((provision) => changeNamesPath(change.parameter!, provision.path)) + : provisions.length === 1 && changes.length === 1 + ? provisions + : []; + const near = (scored: number) => + Math.abs(scored - written.value) <= + written.tolerance + 1e-9 * Math.max(1, Math.abs(written.value)); + return described.some((provision) => { + const scored = [ + provision.value, + ...(provision.intervals ?? []).map((interval) => interval.value), + ].filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + return scored.length > 0 && !scored.some((value) => near(value) || near(value * 100)); + }); + }); +} diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 8061df512..cf4b51eae 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -24,7 +24,11 @@ import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; -import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; +import { + billAlreadyCurrentLaw, + billReportProvisions, + billScoresOtherThanWriteUp, +} from '@/libs/flagship/billProvisions'; import { findTrackedBill, trackerUrlForBillId } from '@/libs/flagship/billTracker'; import { RootState } from '@/store'; @@ -105,6 +109,7 @@ function BillOverviewLead({ bill }: { bill: TrackedBill }) { /** Why a bill has, or does not yet have, full results. */ type RunState = | 'already-law' + | 'write-up-mismatch' | 'no-provisions' | 'no-metadata' | 'not-started' @@ -116,6 +121,11 @@ const UNAVAILABLE_COPY: Partial> = { 'already-law': 'Estimates from the legislative tracker, against the law before this bill', + 'write-up-mismatch': 'Stored estimates from the legislative tracker · full results unavailable', 'no-provisions': 'Stored estimates from the legislative tracker · full results unavailable', 'no-metadata': 'Stored estimates from the legislative tracker · full results unavailable', 'not-started': 'Stored estimates from the legislative tracker', @@ -253,22 +264,30 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) priorLaw: alreadyCurrentLaw, }); + // The Policy changes table shows the tracker's write-up; a run scores the + // stored parameters. When they disagree (a tracker row with its reform + // and baseline swapped), a run would contradict the table above it. + const writeUpMismatch = + !alreadyCurrentLaw && billScoresOtherThanWriteUp(bill?.provisions ?? [], bill?.changes); + const billPaths = provisions.map((p) => p.path); const trackRecord = useModelTrackRecord(billPaths); // Only a reader's request starts the run, and it waits for metadata so an // enacted bill is recognized before any run starts. const economy = useBillEconomy(bill, { - enabled: runRequested && metadataLoaded && !alreadyCurrentLaw, + enabled: runRequested && metadataLoaded && !alreadyCurrentLaw && !writeUpMismatch, }); const runState: RunState = alreadyCurrentLaw ? 'already-law' - : provisions.length === 0 - ? 'no-provisions' - : metadataError && !metadataLoaded - ? 'no-metadata' - : !runRequested - ? 'not-started' - : economy.status; + : writeUpMismatch + ? 'write-up-mismatch' + : provisions.length === 0 + ? 'no-provisions' + : metadataError && !metadataLoaded + ? 'no-metadata' + : !runRequested + ? 'not-started' + : economy.status; const startRun = () => setRunRequestedFor(billId); // A state bill's data check is against that state's calibration targets. const calibration = useCalibrationMatches( diff --git a/app/src/tests/unit/libs/flagship/billProvisions.test.ts b/app/src/tests/unit/libs/flagship/billProvisions.test.ts index 3468c6cc6..5dbb02cba 100644 --- a/app/src/tests/unit/libs/flagship/billProvisions.test.ts +++ b/app/src/tests/unit/libs/flagship/billProvisions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; +import { + billAlreadyCurrentLaw, + billReportProvisions, + billScoresOtherThanWriteUp, +} from '@/libs/flagship/billProvisions'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; const RATE = 'gov.states.ga.tax.income.main.flat_rate'; @@ -127,3 +131,88 @@ describe('billAlreadyCurrentLaw', () => { ).toBe(true); }); }); + +describe('billScoresOtherThanWriteUp', () => { + const UT_RATE = 'gov.states.ut.tax.income.rate'; + const change = (before: string, after: string, parameter?: string) => ({ + label: 'Income tax rate', + before, + after, + ...(parameter ? { parameter } : {}), + }); + + test('given the scored value is the write-up’s law before the bill then they disagree', () => { + // UT SB60 as stored: reform_params 0.045, a write-up of 4.5% → 4.45%. + const provisions = [dated(UT_RATE, ['2026-01-01', '2100-12-31', 0.045])]; + + expect(billScoresOtherThanWriteUp(provisions, [change('4.5%', '4.45%')])).toBe(true); + }); + + test('given the scored value is the write-up’s new value then they agree', () => { + const provisions = [dated(UT_RATE, ['2026-01-01', '2100-12-31', 0.0445])]; + + expect(billScoresOtherThanWriteUp(provisions, [change('4.5%', '4.45%')])).toBe(false); + }); + + test('given a write-up rounded to its last digit then it still agrees', () => { + const provisions = [dated(RATE, ['2026-01-01', '2100-12-31', 0.04449])]; + + expect(billScoresOtherThanWriteUp(provisions, [change('5.19%', '4.45%')])).toBe(false); + expect(billScoresOtherThanWriteUp(provisions, [change('5.19%', '4.4%')])).toBe(false); + }); + + test('given amounts in dollars or bare numbers then they read as written', () => { + const provisions = [{ path: DEDUCTION, value: 2000 }]; + + expect(billScoresOtherThanWriteUp(provisions, [change('$1,500', '$2,000')])).toBe(false); + expect(billScoresOtherThanWriteUp(provisions, [change('$1,500', '$2,500')])).toBe(true); + expect( + billScoresOtherThanWriteUp([{ path: UT_RATE, value: 0.0445 }], [change('4.5', '4.45')]) + ).toBe(false); + }); + + test('given a phased bill then any value it sets can be the one written up', () => { + const provisions = [ + dated(RATE, ['2026-01-01', '2026-12-31', 0.0499], ['2027-01-01', '2100-12-31', 0.0479]), + ]; + + expect(billScoresOtherThanWriteUp(provisions, [change('5.19%', '4.79%')])).toBe(false); + }); + + test('given a change that names its parameter then only that parameter is checked', () => { + const provisions = [ + { path: 'gov.states.ga.tax.income.main.single.brackets[5].rate', value: 0.05 }, + { path: 'gov.states.ga.tax.income.main.joint.brackets[5].rate', value: 0.0575 }, + { path: DEDUCTION, value: 2000 }, + ]; + const parameter = 'gov.states.ga.tax.income.main.{filing_status}.brackets[5].rate'; + + expect(billScoresOtherThanWriteUp(provisions, [change('5.49%', '5%', parameter)])).toBe(true); + expect( + billScoresOtherThanWriteUp(provisions.slice(0, 1).concat(provisions.slice(2)), [ + change('5.49%', '5%', parameter), + ]) + ).toBe(false); + }); + + test('given a write-up that cannot be paired or read as a number then nothing is flagged', () => { + const two = [ + { path: UT_RATE, value: 0.045 }, + { path: DEDUCTION, value: 2000 }, + ]; + + // Two provisions, no parameter named: which one the change describes is unknown. + expect(billScoresOtherThanWriteUp(two, [change('4.5%', '4.45%')])).toBe(false); + // Words, not one number. + expect( + billScoresOtherThanWriteUp( + [{ path: UT_RATE, value: 0.045 }], + [change('Under 7', 'Under 7 (2026) to Under 16 (2035)')] + ) + ).toBe(false); + expect(billScoresOtherThanWriteUp([{ path: UT_RATE, value: 0.045 }], undefined)).toBe(false); + expect(billScoresOtherThanWriteUp([{ path: UT_RATE, value: true }], [change('No', '5%')])).toBe( + false + ); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index da5b2a263..c08ddf1e7 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -379,6 +379,45 @@ describe('BillReportPage', () => { expect(screen.getByText('Winners and losers')).toBeInTheDocument(); }); + test('given the stored parameters disagree with the write-up then no run is offered and the tracker estimate stays', async () => { + const user = userEvent.setup(); + // UT SB60 as the tracker stores it: a write-up of 4.5% → 4.45%, but + // reform params of 0.045, the law before the bill. + mockFetchTrackerBills.mockResolvedValue([ + { + ...TRACKED_BILL, + id: 'ut-sb60-bill', + state: 'UT', + title: 'Utah SB60: Income tax rate reduction (enacted)', + provisions: [ + { + path: 'gov.states.ut.tax.income.rate', + value: 0.045, + intervals: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 0.045 }], + baselineIntervals: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 0.0445 }], + }, + ], + changes: [{ label: 'Income tax rate', before: '4.5%', after: '4.45%' }], + }, + ]); + renderReport('ut-sb60-bill'); + + expect( + await screen.findByText( + 'Stored estimates from the legislative tracker · full results unavailable' + ) + ).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'Economic impacts' })); + const panel = screen.getByRole('tabpanel'); + expect( + within(panel).getByText('The full results are not available for this bill.') + ).toBeInTheDocument(); + expect(within(panel).queryByRole('button', { name: 'Load full results' })).toBeNull(); + // The tracker's own estimate still shows. + expect(within(panel).getByRole('region', { name: 'Budgetary impact' })).toBeInTheDocument(); + expect(mockUseBillEconomy).not.toHaveBeenCalledWith(expect.anything(), { enabled: true }); + }); + test('given the tracker wrote up the changes then the overview lists them as scored', async () => { mockFetchTrackerBills.mockResolvedValue([ { From e36a199e98ad0203d6f162a24b232582497ac852 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:22:47 -0400 Subject: [PATCH 16/40] Reforms: format the bill feed and validation changes Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 4 +--- app/src/tests/unit/api/billFeed.test.ts | 18 +++++++++++++++--- .../flagship/ValidationPanel.test.tsx | 9 +++++---- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index a8cfb5854..1bb55cf29 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -592,9 +592,7 @@ function laterStatusRow(current: any, candidate: any): any { * letters-then-number ("SB3125", "H. 3492", "A 1185"), not a fiscal year. */ function titleBillNumber(title: unknown): string | undefined { - return typeof title === 'string' - ? title.match(/\b(?!FY)([A-Z]{1,3}\.?\s*\d+)/i)?.[1] - : undefined; + return typeof title === 'string' ? title.match(/\b(?!FY)([A-Z]{1,3}\.?\s*\d+)/i)?.[1] : undefined; } /** diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 7d8b482da..836697612 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -310,7 +310,12 @@ describe('tracker reads', () => { bill_number: `HB${index}`, status: 'Introduced', })); - statuses[1417] = { bill_id: 1417, state: 'WV', bill_number: 'SB392', status: 'Signed into Law' }; + statuses[1417] = { + bill_id: 1417, + state: 'WV', + bill_number: 'SB392', + status: 'Signed into Law', + }; const fetchMock = vi.fn(async (url: string) => { const params = new URL(url).searchParams; const table = new URL(url).pathname.split('/').pop(); @@ -329,7 +334,9 @@ describe('tracker reads', () => { : all.slice(offset - 418, offset); return { ok: true, - headers: new Headers({ 'content-range': `${offset}-${offset + page.length - 1}/${all.length}` }), + headers: new Headers({ + 'content-range': `${offset}-${offset + page.length - 1}/${all.length}`, + }), json: async () => page, }; }); @@ -590,7 +597,12 @@ describe('fetchTrackerBills', () => { mockTrackerFetch({ research: [ // The title names the bill, as the tracker reads it. - { id: 'hi-sb3125-hd1', type: 'bill', state: 'HI', title: 'Hawaii SB3125 HD1: tax relief' }, + { + id: 'hi-sb3125-hd1', + type: 'bill', + state: 'HI', + title: 'Hawaii SB3125 HD1: tax relief', + }, { id: 'nj-a1185-marriage-penalty-elimination', type: 'bill', diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index e6d90a73d..34f83026f 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -134,10 +134,11 @@ describe('BillExternalEstimates', () => { const row = within(table).getByRole('row', { name: new RegExp(`^${RANGE_ONLY_FISCAL_NOTE.fiscalNoteSource}`), }); - expect(within(row).getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ - 'No estimate', - '—', - ]); + expect( + within(row) + .getAllByRole('cell') + .map((cell) => cell.textContent) + ).toEqual(['No estimate', '—']); expect(within(row).getByRole('link', { name: 'Read source' })).toHaveAttribute( 'href', RANGE_ONLY_FISCAL_NOTE.fiscalNoteUrl From 37ef7c6d243a7d4c52035baee2271e430bde764e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:23:37 -0400 Subject: [PATCH 17/40] Report: the winners figure counts people, and shows losers when they lead The API's winner shares weight each household by its people, so the overview tile read a share of people as 'Households gaining' and the winners tooltip named the row 'All households'. They now say 'People gaining income' and 'All people'. When more people lose than gain, the tile shows 'People losing income' instead of a bare 0.0% gaining. The tracker's stored figure on a bill report reads the same way. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportContents.tsx | 23 ++++++-- .../flagship/report/StoredImpactCharts.tsx | 3 +- .../WinnersLosersIncomeDecileSubPage.tsx | 5 +- app/src/libs/flagship/billMetrics.ts | 5 +- .../flagship/ReportContents.test.tsx | 55 ++++++++++++++++++- .../WinnersLosersIncomeDecileSubPage.test.tsx | 20 +++++++ .../unit/libs/flagship/billMetrics.test.ts | 21 ++++++- 7 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 app/src/tests/unit/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.test.tsx diff --git a/app/src/components/flagship/ReportContents.tsx b/app/src/components/flagship/ReportContents.tsx index 27b34a911..a8a7ac1e1 100644 --- a/app/src/components/flagship/ReportContents.tsx +++ b/app/src/components/flagship/ReportContents.tsx @@ -24,6 +24,23 @@ export interface ReportMetric { label: string; } +/** + * The overview's winners figure: the share of people whose net income + * rises, or the share whose net income falls when more people lose than + * gain. The winner shares count people, not households: the API weights + * them by the people in each household. + */ +export function winnerShareMetric(gains: number, losses: number): ReportMetric { + const share = (value: number) => `${(value * 100).toFixed(1)}%`; + if (!Number.isFinite(gains)) { + return { value: 'Unavailable', label: 'People gaining income' }; + } + if (Number.isFinite(losses) && losses > gains) { + return { value: share(losses), label: 'People losing income' }; + } + return { value: share(gains), label: 'People gaining income' }; +} + /** * Whether a report leads with state revenue: a state reform (every * provision a state parameter) run on part of the country. A federal @@ -56,6 +73,7 @@ export function reportMetrics( ): ReportMetric[] { const distribution = output.intra_decile.all; const gains = distribution['Gain more than 5%'] + distribution['Gain less than 5%']; + const losses = distribution['Lose more than 5%'] + distribution['Lose less than 5%']; const budget = stateRevenue ? output.budget.state_tax_revenue_impact : output.budget.budgetary_impact; @@ -83,10 +101,7 @@ export function reportMetrics( ? 'Annual government savings' : 'Annual budget change', }, - { - value: Number.isFinite(gains) ? `${(gains * 100).toFixed(1)}%` : 'Unavailable', - label: 'Households gaining', - }, + winnerShareMetric(gains, losses), { value: povertyChange(output.poverty.poverty.child), label: 'Child poverty · relative change', diff --git a/app/src/components/flagship/report/StoredImpactCharts.tsx b/app/src/components/flagship/report/StoredImpactCharts.tsx index e771851f0..cbb4b4166 100644 --- a/app/src/components/flagship/report/StoredImpactCharts.tsx +++ b/app/src/components/flagship/report/StoredImpactCharts.tsx @@ -309,8 +309,9 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im /> [`${Number(value ?? 0).toFixed(1)}%`, String(name)]} + // The shares count people, so the All row is everyone. labelFormatter={(label) => - label === 'All' ? 'All households' : label ? `Decile ${label}` : '' + label === 'All' ? 'All people' : label ? `Decile ${label}` : '' } /> {WINNER_SEGMENTS.map((segment) => ( diff --git a/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx b/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx index 390909797..90a8b964e 100644 --- a/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx +++ b/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx @@ -49,11 +49,12 @@ const TOOLTIP_WRAPPER_STYLE = { maxWidth: 'min(280px, calc(100vw - 32px))', }; -function WinnersLosersTooltip({ active, payload, label }: any) { +/** A row's outcome shares on hover. The shares count people, so "All" is everyone. */ +export function WinnersLosersTooltip({ active, payload, label }: any) { if (!active || !payload?.length) { return null; } - const decileLabel = label === 'All' ? 'All households' : `Decile ${label}`; + const decileLabel = label === 'All' ? 'All people' : `Decile ${label}`; return (

{decileLabel}

diff --git a/app/src/libs/flagship/billMetrics.ts b/app/src/libs/flagship/billMetrics.ts index 00dd8f06e..fc89f3dd4 100644 --- a/app/src/libs/flagship/billMetrics.ts +++ b/app/src/libs/flagship/billMetrics.ts @@ -1,5 +1,5 @@ import type { TrackedBill } from '@/api/billFeed'; -import type { ReportMetric } from '@/components/flagship/ReportContents'; +import { winnerShareMetric, type ReportMetric } from '@/components/flagship/ReportContents'; import { formatCurrencyAbbr } from '@/utils/formatters'; function relativeChange(percentChange: number): string { @@ -36,7 +36,8 @@ export function storedBillMetrics( const winners = impact?.winnersLosers; if (winners && (winners.gainMore5Pct !== undefined || winners.gainLess5Pct !== undefined)) { const gains = (winners.gainMore5Pct ?? 0) + (winners.gainLess5Pct ?? 0); - metrics.push({ value: `${(gains * 100).toFixed(1)}%`, label: 'Households gaining' }); + const losses = (winners.loseMore5Pct ?? 0) + (winners.loseLess5Pct ?? 0); + metrics.push(winnerShareMetric(gains, losses)); } const childPoverty = impact?.childPoverty?.percentChange; diff --git a/app/src/tests/unit/components/flagship/ReportContents.test.tsx b/app/src/tests/unit/components/flagship/ReportContents.test.tsx index 9d70a6153..afb6bf831 100644 --- a/app/src/tests/unit/components/flagship/ReportContents.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportContents.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { leadsWithStateRevenue, reportMetrics } from '@/components/flagship/ReportContents'; +import { + leadsWithStateRevenue, + reportMetrics, + winnerShareMetric, +} from '@/components/flagship/ReportContents'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; describe('reportMetrics', () => { @@ -19,6 +23,55 @@ describe('reportMetrics', () => { label: 'Annual state revenue loss', }); }); + + test('given more people gain than lose then the second figure is the share of people gaining', () => { + // Given: the API's winner shares count people, as in report 4326. + const output = createMockSocietyWideOutput(); + output.intra_decile.all = { + 'Gain more than 5%': 0.041, + 'Gain less than 5%': 0.1, + 'No change': 0.849, + 'Lose less than 5%': 0.01, + 'Lose more than 5%': 0, + }; + + // Then + expect(reportMetrics(output, 'us')[1]).toEqual({ + value: '14.1%', + label: 'People gaining income', + }); + }); + + test('given more people lose than gain then the second figure is the share of people losing', () => { + // Given: 46% of people lose a little and no one gains, as in report 4302. + const output = createMockSocietyWideOutput(); + output.intra_decile.all = { + 'Gain more than 5%': 0, + 'Gain less than 5%': 0, + 'No change': 0.5398, + 'Lose less than 5%': 0.4602, + 'Lose more than 5%': 0, + }; + + // Then + expect(reportMetrics(output, 'us')[1]).toEqual({ + value: '46.0%', + label: 'People losing income', + }); + }); +}); + +describe('winnerShareMetric', () => { + test('given no one gains or loses then it reads as no one gaining', () => { + expect(winnerShareMetric(0, 0)).toEqual({ value: '0.0%', label: 'People gaining income' }); + }); + + test('given shares that are not numbers then it is unavailable', () => { + expect(winnerShareMetric(Number.NaN, Number.NaN)).toEqual({ + value: 'Unavailable', + label: 'People gaining income', + }); + }); }); describe('leadsWithStateRevenue', () => { diff --git a/app/src/tests/unit/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.test.tsx b/app/src/tests/unit/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.test.tsx new file mode 100644 index 000000000..ee8610d30 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import { WinnersLosersTooltip } from '@/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage'; + +const PAYLOAD = [{ dataKey: 'Gain less than 5%', value: 0.141, fill: '#319795' }]; + +describe('WinnersLosersTooltip', () => { + test('given the All row then it names everyone the shares count: people', () => { + render(); + + expect(screen.getByText('All people')).toBeInTheDocument(); + expect(screen.queryByText('All households')).not.toBeInTheDocument(); + }); + + test('given a decile row then it names the decile', () => { + render(); + + expect(screen.getByText('Decile 3')).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/billMetrics.test.ts b/app/src/tests/unit/libs/flagship/billMetrics.test.ts index 01a34fc27..fa8f28a05 100644 --- a/app/src/tests/unit/libs/flagship/billMetrics.test.ts +++ b/app/src/tests/unit/libs/flagship/billMetrics.test.ts @@ -6,11 +6,30 @@ describe('storedBillMetrics', () => { test('given full stored impacts then returns the overview metrics in report form', () => { expect(storedBillMetrics(TRACKED_BILL, 'us')).toEqual([ { value: '$225.5bn', label: 'Annual revenue loss' }, - { value: '43.5%', label: 'Households gaining' }, + { value: '43.5%', label: 'People gaining income' }, { value: '39.9% decrease', label: 'Child poverty · relative change' }, ]); }); + test('given more people lose than gain then the stored figure is the share losing', () => { + expect( + storedBillMetrics( + { + impactData: { + winnersLosers: { + gainMore5Pct: 0, + gainLess5Pct: 0.02, + noChange: 0.68, + loseLess5Pct: 0.25, + loseMore5Pct: 0.05, + }, + }, + }, + 'us' + ) + ).toEqual([{ value: '30.0%', label: 'People losing income' }]); + }); + test('given only headline impacts then falls back to overall poverty', () => { expect( storedBillMetrics({ impacts: { revenue: -74_200_000, povertyPercentChange: 0 } }, 'us') From 3bbb49139d36ce6635e4280b5b962594b6c68f83 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:23:44 -0400 Subject: [PATCH 18/40] Report: a recompute applies an edited value from the report's year An edited provision ran, and was saved, as one value from 2026, so a 2024 or 2025 report recomputed to no change. An edit now holds from 1 January of the report's year, over the provision's earlier dated values, as Build's inline edit does, and the saved reform has the same dates as the run. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportAdjustPanel.tsx | 101 +++++++++++------- .../flagship/reportAdjustPanelMocks.ts | 30 ++++++ .../flagship/ReportAdjustPanel.test.tsx | 87 ++++++++++++++- 3 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 696725ea9..97e96a57d 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -9,12 +9,17 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { loadProvisionsIntoDraft, sameParameterValue } from '@/libs/draftReform'; +import { + loadProvisionsIntoDraft, + provisionIntervals, + sameParameterValue, +} from '@/libs/draftReform'; import { reportProvisionName } from '@/libs/flagship/draftLabels'; import { RunReportBaseline, RunReportProvision } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; import { Reform } from '@/types/ingredients/Reform'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { formatValue } from '@/utils/parameterValues'; import ProvisionSkeleton from './ProvisionSkeleton'; import PopulationPicker from './reform/PopulationPicker'; @@ -22,6 +27,20 @@ import NumberInput from './reform/ValueInput'; import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; +/** True when the reform's parameter set is exactly the given provisions. */ +function reformMatches(reform: Reform, provisions: { path: string; value: any }[]): boolean { + if (reform.parameters.length !== provisions.length) { + return false; + } + return provisions.every((provision) => + reform.parameters.some( + (parameter) => + parameter.name === provision.path && + String(parameter.values[0]?.value) === String(provision.value) + ) + ); +} + interface ReportAdjustPanelProps { /** Title of the report being adjusted; the recomputed one appends "(adjusted)". */ title: string; @@ -42,20 +61,6 @@ interface ReportAdjustPanelProps { baselineUnknown?: boolean; } -/** True when the reform's parameter set is exactly the given provisions. */ -function reformMatches(reform: Reform, provisions: { path: string; value: any }[]): boolean { - if (reform.parameters.length !== provisions.length) { - return false; - } - return provisions.every((provision) => - reform.parameters.some( - (parameter) => - parameter.name === provision.path && - String(parameter.values[0]?.value) === String(provision.value) - ) - ); -} - /** * The report's companion panel: every provision stays editable (or * removable) beside the results. Recompute first reconciles with the @@ -129,17 +134,38 @@ function AdjustPanel({ return null; } + // The year a recompute simulates; the current year when the report has none. + const runYear = year ? Number(year) : undefined; + /** - * The provisions as the panel has them. A value edited here replaces the - * provision's own dates — one value, from the report's year on — or the - * run would keep the dates and ignore the edit. + * The provisions as the panel has them. A value edited here holds from + * 1 January of the report's year on, over whatever dated values come + * before it (as Build's inline edit sets it): left on the provision's + * old dates, or on the current year's, the run would miss the edit. */ - const adjustedProvisions = () => + const adjustedProvisions = (): RunReportProvision[] => active.map((provision) => { const value = values[provision.path]; - return sameParameterValue(value, provision.value) - ? { ...provision } - : { ...provision, value, intervals: undefined }; + if (sameParameterValue(value, provision.value)) { + return { ...provision }; + } + const schedule = new ValueIntervalCollection(provisionIntervals(provision)); + schedule.addInterval({ + startDate: `${runYear ?? CURRENT_YEAR}-01-01`, + endDate: FOREVER, + value, + }); + return { + ...provision, + value, + intervals: schedule.getIntervals(), + // A value held from one year on no longer grows as the old one did. + growth: undefined, + growthStart: undefined, + growthBase: undefined, + growthRoundTo: undefined, + growthRound: undefined, + }; }); // Back to Build with the reform as the panel has it, to add to it there. @@ -147,7 +173,7 @@ function AdjustPanel({ const label = name.trim() || title; loadProvisionsIntoDraft(countryId, adjustedProvisions(), { label: label === 'Draft reform' ? '' : label, - year: year ? Number(year) : undefined, + year: runYear, region: population, baselineReformId: baseline?.reformId, }); @@ -159,6 +185,12 @@ function AdjustPanel({ setIsReconciling(true); try { const adjusted = adjustedProvisions(); + // The reform as it runs: every provision with the dated values the + // run sends (see provisionPeriods), so what is saved is what ran. + const parameters = adjusted.map((provision) => ({ + name: provision.path, + values: provisionIntervals(provision), + })); // Reconcile with this browser's saved reforms: reuse an exact // match, otherwise save the adjusted set as its own reform. @@ -176,16 +208,7 @@ function AdjustPanel({ const created = await store.create({ countryId, label, - parameters: adjusted.map((provision) => ({ - name: provision.path, - values: [ - { - startDate: `${CURRENT_YEAR}-01-01`, - endDate: FOREVER, - value: provision.value, - }, - ], - })), + parameters, baseline: 'current-law', provenance: { source: 'manual', ref: 'report-adjust' }, }); @@ -194,14 +217,10 @@ function AdjustPanel({ runTitle = created.label || label; } - await runReport.run( - runTitle, - sourceNote, - adjusted, - reformId, - year ? Number(year) : undefined, - { region: population, baseline } - ); + await runReport.run(runTitle, sourceNote, adjusted, reformId, runYear, { + region: population, + baseline, + }); } catch { setReconcileError('Could not start the recompute. Try again.'); } finally { diff --git a/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts b/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts new file mode 100644 index 000000000..cdad06b6f --- /dev/null +++ b/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts @@ -0,0 +1,30 @@ +import type { RunReportProvision } from '@/libs/flagship/runReport'; + +export const CTC_BASE_PATH = 'gov.irs.credits.ctc.amount.base[0].amount'; + +/** A report year before the current one: the year picker offers it. */ +export const PAST_REPORT_YEAR = '2024'; +/** A report year after the first year of a phased reform. */ +export const PHASED_REPORT_YEAR = '2027'; + +/** One Child Tax Credit value from the current year on, as most reports run it. */ +export const FLAT_CTC_PROVISION: RunReportProvision = { + path: CTC_BASE_PATH, + breadcrumb: 'IRS → Credits → Child tax credit → Base amount', + unit: 'currency-USD', + baselineValue: 2000, + value: 2500, +}; + +/** A phased Child Tax Credit: 2,500 in 2026, then 3,000 from 2027 on. */ +export const PHASED_CTC_INTERVALS = [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 2500 }, + { startDate: '2027-01-01', endDate: '2100-12-31', value: 3000 }, +]; + +/** The phased credit as a 2027 report shows it: 3,000 that year. */ +export const PHASED_CTC_PROVISION: RunReportProvision = { + ...FLAT_CTC_PROVISION, + value: 3000, + intervals: PHASED_CTC_INTERVALS, +}; diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index 9f4000acc..d99db91ef 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -3,13 +3,23 @@ import { render, screen, userEvent, waitFor } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; -import type { RunReportBaseline } from '@/libs/flagship/runReport'; +import { + provisionPeriods, + type RunReportBaseline, + type RunReportProvision, +} from '@/libs/flagship/runReport'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata, US_REGIONS, } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + FLAT_CTC_PROVISION, + PAST_REPORT_YEAR, + PHASED_CTC_PROVISION, + PHASED_REPORT_YEAR, +} from '@/tests/fixtures/components/flagship/reportAdjustPanelMocks'; const mockRun = vi.fn(); const mockFindMine = vi.fn(); @@ -243,9 +253,9 @@ describe('ReportAdjustPanel', () => { await user.click(screen.getByRole('button', { name: /recompute/i })); await waitFor(() => expect(mockRun).toHaveBeenCalled()); - expect(mockRun.mock.calls[0][2]).toEqual([ - expect.objectContaining({ value: 3600, intervals: undefined }), - ]); + expect(provisionPeriods(mockRun.mock.calls[0][2][0])).toEqual({ + '2026-01-01.2100-12-31': 3600, + }); }); test('given the reform is renamed then recompute saves and titles it with the name', async () => { @@ -416,4 +426,73 @@ describe('ReportAdjustPanel', () => { await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); expect(screen.getByRole('button', { name: /recompute/i })).toBeInTheDocument(); }); + + describe('given the report year and the provision’s dates', () => { + function renderOne(provision: RunReportProvision, year: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); + } + + async function editAndRecompute(value: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + const input = screen.getByLabelText(/new value for maximum credit per child/i); + await user.clear(input); + await user.type(input, value); + await user.click(screen.getByRole('button', { name: /recompute/i })); + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + } + + test('given a past-year report then an edited value runs from that year', async () => { + // Given + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + renderOne(FLAT_CTC_PROVISION, PAST_REPORT_YEAR); + + // When + await editAndRecompute('3000'); + + // Then + const [, , provisions, , year] = mockRun.mock.calls[0]; + expect(year).toBe(Number(PAST_REPORT_YEAR)); + expect(provisionPeriods(provisions[0])).toEqual({ '2024-01-01.2100-12-31': 3000 }); + }); + + test('given a past-year report then the saved reform sets the value from that year', async () => { + // Given + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + renderOne(FLAT_CTC_PROVISION, PAST_REPORT_YEAR); + + // When + await editAndRecompute('3000'); + + // Then + expect(mockCreate.mock.calls[0][0].parameters[0].values).toEqual([ + { startDate: '2024-01-01', endDate: '2100-12-31', value: 3000 }, + ]); + }); + + test('given a phased provision then an edit holds from the report year, after its earlier dates', async () => { + // Given + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + renderOne(PHASED_CTC_PROVISION, PHASED_REPORT_YEAR); + + // When + await editAndRecompute('3600'); + + // Then + expect(provisionPeriods(mockRun.mock.calls[0][2][0])).toEqual({ + '2026-01-01.2026-12-31': 2500, + '2027-01-01.2100-12-31': 3600, + }); + }); + }); }); From bc8f0cefe0cc274ab67a7b1196c84fb353bdc575 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:24:22 -0400 Subject: [PATCH 19/40] Report: a recompute reuses a saved reform only when its dates match Recompute matched saved reforms on parameter names and the first value alone, so a phased reform could run linked to, and titled as, a flat one. It now uses findMatchingReform, as Build does: a saved reform is reused only when it sets the same values over the same dates. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportAdjustPanel.tsx | 23 ++----- .../flagship/reportAdjustPanelMocks.ts | 21 ++++++ .../flagship/ReportAdjustPanel.test.tsx | 68 +++++++++++++++++++ .../pages/flagship/ReportSharedLink.test.tsx | 2 + 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 97e96a57d..c384f0783 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -15,9 +15,9 @@ import { sameParameterValue, } from '@/libs/draftReform'; import { reportProvisionName } from '@/libs/flagship/draftLabels'; +import { findMatchingReform } from '@/libs/flagship/reformMatch'; import { RunReportBaseline, RunReportProvision } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; -import { Reform } from '@/types/ingredients/Reform'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { formatValue } from '@/utils/parameterValues'; @@ -27,20 +27,6 @@ import NumberInput from './reform/ValueInput'; import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; -/** True when the reform's parameter set is exactly the given provisions. */ -function reformMatches(reform: Reform, provisions: { path: string; value: any }[]): boolean { - if (reform.parameters.length !== provisions.length) { - return false; - } - return provisions.every((provision) => - reform.parameters.some( - (parameter) => - parameter.name === provision.path && - String(parameter.values[0]?.value) === String(provision.value) - ) - ); -} - interface ReportAdjustPanelProps { /** Title of the report being adjusted; the recomputed one appends "(adjusted)". */ title: string; @@ -192,11 +178,10 @@ function AdjustPanel({ values: provisionIntervals(provision), })); - // Reconcile with this browser's saved reforms: reuse an exact - // match, otherwise save the adjusted set as its own reform. + // Reconcile with this browser's saved reforms: reuse one that sets + // the same values over the same dates, otherwise save this one. const store = getReformStore(); - const existing = await store.findMine(countryId); - const match = existing.find((reform) => reformMatches(reform, adjusted)); + const match = findMatchingReform(await store.findMine(countryId), parameters); const label = renamed ? name.trim() : `${title} (adjusted)`; let reformId: string; diff --git a/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts b/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts index cdad06b6f..05dbeda48 100644 --- a/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts +++ b/app/src/tests/fixtures/components/flagship/reportAdjustPanelMocks.ts @@ -1,4 +1,6 @@ import type { RunReportProvision } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; +import type { ValueInterval } from '@/types/subIngredients/valueInterval'; export const CTC_BASE_PATH = 'gov.irs.credits.ctc.amount.base[0].amount'; @@ -28,3 +30,22 @@ export const PHASED_CTC_PROVISION: RunReportProvision = { value: 3000, intervals: PHASED_CTC_INTERVALS, }; + +const savedReform = (id: string, label: string, values: ValueInterval[]): Reform => ({ + id, + countryId: 'us', + label, + parameters: [{ name: CTC_BASE_PATH, values }], + baseline: 'current-law', + provenance: { source: 'manual' }, + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', +}); + +/** A saved reform that sets the 2027 value from 2026 on: the same value, other dates. */ +export const FLAT_3000_REFORM = savedReform('rf-flat', 'Flat 3000', [ + { startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }, +]); + +/** A saved reform with the phased schedule itself. */ +export const PHASED_CTC_REFORM = savedReform('rf-phased', 'Phased CTC', PHASED_CTC_INTERVALS); diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index d99db91ef..30895e647 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -15,9 +15,12 @@ import { US_REGIONS, } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; import { + FLAT_3000_REFORM, FLAT_CTC_PROVISION, PAST_REPORT_YEAR, + PHASED_CTC_INTERVALS, PHASED_CTC_PROVISION, + PHASED_CTC_REFORM, PHASED_REPORT_YEAR, } from '@/tests/fixtures/components/flagship/reportAdjustPanelMocks'; @@ -494,5 +497,70 @@ describe('ReportAdjustPanel', () => { '2027-01-01.2100-12-31': 3600, }); }); + + test('given only the population changes then a saved reform with other dates is not reused', async () => { + // Given + seedDeductionMetadata({ regions: US_REGIONS }); + mockFindMine.mockResolvedValue([FLAT_3000_REFORM]); + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + const user = userEvent.setup(); + renderOne(PHASED_CTC_PROVISION, PHASED_REPORT_YEAR); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // When + await user.click(screen.getByRole('button', { name: 'Population: Nationwide' })); + await user.click(await screen.findByRole('option', { name: /California/ })); + await user.click(screen.getByRole('button', { name: /recompute/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + const [runTitle, , , reformId] = mockRun.mock.calls[0]; + expect(reformId).toBe('rf-new'); + expect(runTitle).toBe('CTC expansion (adjusted)'); + store.dispatch(clearMetadata()); + }); + + test('given no saved match then the saved reform has the dates that ran', async () => { + // Given + seedDeductionMetadata({ regions: US_REGIONS }); + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + const user = userEvent.setup(); + renderOne(PHASED_CTC_PROVISION, PHASED_REPORT_YEAR); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // When + await user.click(screen.getByRole('button', { name: 'Population: Nationwide' })); + await user.click(await screen.findByRole('option', { name: /California/ })); + await user.click(screen.getByRole('button', { name: /recompute/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + expect(mockCreate.mock.calls[0][0].parameters[0].values).toEqual(PHASED_CTC_INTERVALS); + expect(provisionPeriods(mockRun.mock.calls[0][2][0])).toEqual({ + '2026-01-01.2026-12-31': 2500, + '2027-01-01.2100-12-31': 3000, + }); + store.dispatch(clearMetadata()); + }); + + test('given the same dated reform is saved then it is reused', async () => { + // Given + seedDeductionMetadata({ regions: US_REGIONS }); + mockFindMine.mockResolvedValue([FLAT_3000_REFORM, PHASED_CTC_REFORM]); + const user = userEvent.setup(); + renderOne(PHASED_CTC_PROVISION, PHASED_REPORT_YEAR); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // When + await user.click(screen.getByRole('button', { name: 'Population: Nationwide' })); + await user.click(await screen.findByRole('option', { name: /California/ })); + await user.click(screen.getByRole('button', { name: /recompute/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockRun.mock.calls[0][3]).toBe(PHASED_CTC_REFORM.id); + store.dispatch(clearMetadata()); + }); }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx index 761e7fa29..327fff35d 100644 --- a/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportSharedLink.test.tsx @@ -156,6 +156,8 @@ describe('FlagshipReportPage from a shared link', () => { const [, , provisions, , year] = mockRun.mock.calls[0]; expect(year).toBe(Number(REPORT_YEAR_2028)); expect(provisionPeriods(provisions[0])).toEqual(EXEMPTION_SCHEDULE_PERIODS); + // Saved to Your reforms as it ran. + expect(mockCreate.mock.calls[0][0].parameters[0].values).toEqual(EXEMPTION_SCHEDULE); }); test('given a failed run then Edit the reform puts the dated reform back in the draft', async () => { From 68210dc7038fc0eeda4ab5dc946fea6045cde3dc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:24:36 -0400 Subject: [PATCH 20/40] Report: every bar keeps its category label at phone width Recharts' default tick interval hid category labels that crowd, so on a 375px screen the Hispanic bar, the Top 10% share bar and the state tax bar had a value and no name. The report's bar charts and the budget waterfall now show every tick: each label wraps by word to its own slot, the font shrinks until the longest word fits, and the x-axis grows to hold the label with the most lines. Co-Authored-By: Claude Opus 5.5 --- .../charts/DeepPovertyImpactByAgeSubPage.tsx | 16 +- .../DeepPovertyImpactByGenderSubPage.tsx | 9 +- .../report/charts/InequalityImpactSubPage.tsx | 9 +- .../charts/PovertyImpactByAgeSubPage.tsx | 16 +- .../charts/PovertyImpactByGenderSubPage.tsx | 9 +- .../charts/PovertyImpactByRaceSubPage.tsx | 9 +- .../report/charts/ReportWaterfallChart.tsx | 13 +- .../flagship/report/charts/barLayout.ts | 73 +++++++++ .../report/charts/reportChartMocks.ts | 3 + .../report/charts/CategoryAxisLabels.test.tsx | 150 ++++++++++++++++++ .../flagship/report/charts/barLayout.test.ts | 59 +++++++ 11 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 app/src/tests/unit/components/flagship/report/charts/CategoryAxisLabels.test.tsx diff --git a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx index de305eafa..c293faafd 100644 --- a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx +++ b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx @@ -18,6 +18,7 @@ import ChartExplanation from '@/components/flagship/report/ChartExplanation'; import { fitAxisTitle, fixedPlotHeight, + getCategoryAxis, getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; @@ -142,6 +143,14 @@ export default function DeepPovertyImpactByAgeSubPage({ { width: plotWidth, height: plotHeight } ); const { domain: yDomain, ticks: yTicks } = layout; + // A card says "Adults"; every group keeps its label, wrapped to its slot. + const categoryLabel = (name: string) => + compact && name === 'Working-age adults' ? 'Adults' : name; + const categoryAxis = getCategoryAxis( + chartData.map((d) => categoryLabel(d.name)), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,9 +177,10 @@ export default function DeepPovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + interval={0} + tick={categoryAxis.tick} + height={categoryAxis.height} + tickFormatter={categoryLabel} /> d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +175,7 @@ export default function DeepPovertyImpactByGenderSubPage({ {probe} - + d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals to write every tick exactly. const tickDigits = tickDecimals(yTicks.map((v) => v * 100)); @@ -155,7 +162,7 @@ export default function InequalityImpactSubPage({ {probe} - + + compact && name === 'Working-age adults' ? 'Adults' : name; + const categoryAxis = getCategoryAxis( + chartData.map((d) => categoryLabel(d.name)), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -167,9 +176,10 @@ export default function PovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + interval={0} + tick={categoryAxis.tick} + height={categoryAxis.height} + tickFormatter={categoryLabel} /> d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +175,7 @@ export default function PovertyImpactByGenderSubPage({ {probe} - + d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -165,7 +172,7 @@ export default function PovertyImpactByRaceSubPage({ {probe} - + datum.name), + plotWidth, + RECHARTS_FONT_STYLE + ); const decimals = tickDecimals(ticks); const tickFormatter = (value: number) => formatTick(value, decimals); const yAxisTitle = fitAxisTitle(yAxisTitles, plotHeight); @@ -128,7 +135,9 @@ export default function ReportWaterfallChart({ diff --git a/app/src/components/flagship/report/charts/barLayout.ts b/app/src/components/flagship/report/charts/barLayout.ts index 68d6f7763..a27159532 100644 --- a/app/src/components/flagship/report/charts/barLayout.ts +++ b/app/src/components/flagship/report/charts/barLayout.ts @@ -447,3 +447,76 @@ export function fitAxisTitle( ): string | undefined { return candidates.find((title) => measure(title, VALUE_LABEL_FONT_SIZE) <= plotHeight); } + +// --------------------------------------------------------------------------- +// Category axis +// --------------------------------------------------------------------------- + +/** Clear space kept between two category labels side by side, px. */ +const CATEGORY_LABEL_SIDE_GAP = 4; +/** From the x-axis line to a category label's first line: Recharts' tick size and margin, px. */ +const CATEGORY_LABEL_OFFSET = 8; +/** Room under a category label's last line for its descenders, px. */ +const CATEGORY_LABEL_DESCENT = 2; + +/** The lines a label wraps to, by word, in `width` px. A word wider than that keeps its own line. */ +export function wrapLabel( + label: string, + width: number, + fontSize: number, + measure: MeasureText = measureTextWidth +): string[] { + const lines: string[] = []; + for (const word of label.split(/\s+/).filter(Boolean)) { + const last = lines[lines.length - 1]; + if (last !== undefined && measure(`${last} ${word}`, fontSize) <= width) { + lines[lines.length - 1] = `${last} ${word}`; + } else { + lines.push(word); + } + } + return lines; +} + +export interface CategoryAxis