From 9ae810727e421e01bce77bf3df40c49133843f0e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:11:54 -0400 Subject: [PATCH 01/26] 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 2e15b9f1341067be4b77301f1504629e16da5ea1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:13:27 -0400 Subject: [PATCH 02/26] 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 03/26] 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 04/26] 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 05/26] 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 06/26] 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 07/26] 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 08/26] 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 09/26] 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 10/26] 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 f721182ce424b2ed26d56dbc23cc5de0fbb52c4a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:21:21 -0400 Subject: [PATCH 11/26] 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 12/26] 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 68210dc7038fc0eeda4ab5dc946fea6045cde3dc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:24:36 -0400 Subject: [PATCH 13/26] 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