From 2e15b9f1341067be4b77301f1504629e16da5ea1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:13:27 -0400 Subject: [PATCH 01/19] 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 02/19] 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 e5f7b37dabcc097c67048825104f12d3e48f54c6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:16 -0400 Subject: [PATCH 03/19] 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 04/19] 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 05/19] 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 06/19] 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 cab31f94bc16654860e04ff9911c90640cf06f8c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:18:31 -0400 Subject: [PATCH 07/19] 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 08/19] 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 09/19] 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 a1146d21841ad93c1ccf00457eef96f7ed7deb15 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:48:26 -0400 Subject: [PATCH 10/19] Reforms: leave the tracker row order to the security fix Revert 2e15b9f1. The paged processed_bills read and the rule for two status rows under one bill number belong to #1277 (fix/v3-audit-security) only. The feed reads processed_bills without last_action_date again, and the title and id-prefix stage join stays. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 49 +++------------ app/src/tests/unit/api/billFeed.test.ts | 81 ------------------------- 2 files changed, 9 insertions(+), 121 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 1bb55cf29..3c4d87628 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -186,22 +186,8 @@ 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: TrackerTable, + table: string, select: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string @@ -213,7 +199,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}` : ''}&order=${TRACKER_ROW_ORDER[table]}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, @@ -574,19 +560,6 @@ 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 bill number a title names, as the tracker reads it: the first * letters-then-number ("SB3125", "H. 3492", "A 1185"), not a fiscal year. @@ -651,10 +624,7 @@ 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,last_action_date' - ), + trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url'), 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' @@ -675,13 +645,12 @@ export async function fetchTrackerBills(): Promise { bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), 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)); - } + const processedByKey = new Map( + (processed ?? []).map((bill) => [ + processedBillKey(String(bill.state), String(bill.bill_number)), + 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 836697612..b5ebfa940 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -300,87 +300,6 @@ 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 e811d54487802eeba51ef05649102869fbf509f5 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:49:00 -0400 Subject: [PATCH 11/19] Reforms: test a bill that only its title joins to its stage The id "sc-income-tax-cut" names no bill number, so only the title's "H. 3492" finds the processed_bills row. The test fails when the title candidate is removed from processedBillFor. Co-Authored-By: Claude Opus 5.5 --- app/src/tests/unit/api/billFeed.test.ts | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index b5ebfa940..fde723a43 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -553,6 +553,32 @@ describe('fetchTrackerBills', () => { ]); }); + test('given an id that names no bill number then the stage joins by the title', 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({ + // Neither the whole id nor its first part is a bill number. + research: [ + { + id: 'sc-income-tax-cut', + type: 'bill', + state: 'SC', + title: 'South Carolina H. 3492: Income tax cut', + }, + ], + processed_bills: [ + { bill_id: 5, state: 'SC', bill_number: 'H3492', status: 'Passed One Chamber' }, + ], + }) + ); + + const bills = await fetchTrackerBills(); + + expect(bills![0].status).toBe('Passed One Chamber'); + }); + 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 835e255f0156290aba16084bf22a032a942a1dfc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:53:16 -0400 Subject: [PATCH 12/19] Reforms: a bill held twice keeps the row with the later action When two status rows share one bill number (NY "S04487" from LegiScan and "S 4487" from OpenStates), the row with the later last_action_date now wins. On the same day, or with no dates, the row that links to LegiScan wins. The bill id is only a fixed tie-break after that: the OpenStates and Congress monitors hash their ids above 10,000,000, so a higher id does not mean a later bill, and the OpenStates row always won before. The test rows now carry realistic ids for each source. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 82 ++++++++++++++------- app/src/tests/fixtures/api/billFeedMocks.ts | 44 +++++++++-- app/src/tests/unit/api/billFeed.test.ts | 46 +++++++----- 3 files changed, 122 insertions(+), 50 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index b6dd0c460..02e52df6d 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -568,29 +568,56 @@ 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 => { +/** A status row's last action day ("2026-03-04"), or "" when it has none. */ +const lastActionDay = (bill: any): string => { + const day = typeof bill.last_action_date === 'string' ? bill.last_action_date.slice(0, 10) : ''; + return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : ''; +}; + +/** + * Whether a status row came from LegiScan, read from its link: the + * OpenStates and Congress monitors store their own sites' links in the + * same column, and the row's source column defaults to "legiscan". + */ +const isLegiscanRow = (bill: any): boolean => + /^https?:\/\/(www\.)?legiscan\.com\//i.test(String(bill.legiscan_url ?? '')); + +/** A status row's bill id as a number, lowest when it has none. */ +const statusRowId = (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]); + JSON.stringify([ + bill.bill_id, + bill.state, + bill.bill_number, + bill.status, + bill.legiscan_url, + bill.last_action_date, + ]); + +const ascending = (a: number | string, b: number | string) => (a < b ? -1 : a > b ? 1 : 0); /** - * 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. + * Orders two status rows for one bill number, the preferred row last. + * The tracker can hold a bill twice under two spellings of its number: + * LegiScan's (NY "S04487") and OpenStates' ("S 4487"). The row with the + * later last action wins. On the same day, or with no dates, the LegiScan + * row wins: it names the stage more finely ("In Committee", where + * OpenStates says "Introduced") and links to LegiScan. The bill id and + * then the row's contents only fix the order of what is left. They do + * not date a bill: OpenStates and Congress ids are hashes, all above + * LegiScan's. The same row wins in any read order. */ -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; +function compareStatusRows(a: any, b: any): number { + return ( + ascending(lastActionDay(a), lastActionDay(b)) || + ascending(Number(isLegiscanRow(a)), Number(isLegiscanRow(b))) || + ascending(statusRowId(a), statusRowId(b)) || + ascending(statusRowText(a), statusRowText(b)) + ); } /** @@ -634,7 +661,11 @@ export async function fetchTrackerBills(): Promise { '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', 'bill_id'), + trackerSelect( + 'processed_bills', + 'bill_id,state,bill_number,status,legiscan_url,last_action_date', + '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', @@ -651,19 +682,20 @@ 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( - processedRows.flatMap((bill) => + (processed ?? []).flatMap((bill) => bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); - const processedByKey = new Map( - processedRows.map((bill) => [ - processedBillKey(String(bill.state), String(bill.bill_number)), - bill, - ]) - ); + // Two rows can share a bill number's key: the preferred one holds it. + const processedByKey = new Map(); + for (const bill of processed ?? []) { + const key = processedBillKey(String(bill.state), String(bill.bill_number)); + const held = processedByKey.get(key); + if (!held || compareStatusRows(held, bill) < 0) { + processedByKey.set(key, bill); + } + } return research .filter((record) => isBillRecord(record)) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 5247f83a6..318ae2029 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -151,25 +151,59 @@ export const MANY_BILL_ROWS: TrackerTableRows = { })), }; -/** NY S 4487, filed twice in the status table under two spellings of its number. */ +/** NY S 4487, held 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 = { +/** LegiScan's row: a LegiScan bill id (about 2 million) and its finer stage. */ +export const TWICE_FILED_LEGISCAN = { 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, +/** OpenStates' row: the monitor hashes its bill id to 10,000,000 or more. */ +export const TWICE_FILED_OPENSTATES = { + bill_id: 47_123_456, state: 'NY', bill_number: 'S 4487', status: 'Introduced', legiscan_url: 'https://openstates.org/ny/bills/2025-2026/S4487/', } as const; + +/** The two rows' last action dates, and the row that keeps the bill number. */ +export const TWICE_FILED_CASES = [ + { + when: 'the LegiScan row acted last', + winnerName: 'LegiScan', + legiscanDate: '2025-05-02', + openstatesDate: '2025-03-10', + winner: TWICE_FILED_LEGISCAN, + }, + { + when: 'the OpenStates row acted last', + winnerName: 'OpenStates', + legiscanDate: '2025-03-10', + openstatesDate: '2025-05-02', + winner: TWICE_FILED_OPENSTATES, + }, + { + // A timestamp still reads as its day. + when: 'both acted the same day', + winnerName: 'LegiScan', + legiscanDate: '2025-03-10', + openstatesDate: '2025-03-10T14:30:00+00:00', + winner: TWICE_FILED_LEGISCAN, + }, + { + when: 'neither has a date', + winnerName: 'LegiScan', + legiscanDate: null, + openstatesDate: null, + winner: TWICE_FILED_LEGISCAN, + }, +]; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 4d9dba7eb..725b354bb 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -20,8 +20,9 @@ import { NET_COST_ONLY_ROWS, TRACKER_NAMING_ROWS, TWICE_FILED_BILL, - TWICE_FILED_EARLIER, - TWICE_FILED_LATER, + TWICE_FILED_CASES, + TWICE_FILED_LEGISCAN, + TWICE_FILED_OPENSTATES, } from '@/tests/fixtures/api/billFeedMocks'; import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; @@ -345,25 +346,30 @@ describe('tracker reads', () => { }); }); - 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]); + test.each(TWICE_FILED_CASES)( + 'given two status rows under one bill number when $when then the $winnerName row wins in any read order', + async ({ legiscanDate, openstatesDate, winner }) => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const legiscan = { ...TWICE_FILED_LEGISCAN, last_action_date: legiscanDate }; + const openstates = { ...TWICE_FILED_OPENSTATES, last_action_date: openstatesDate }; + const loads = []; + for (const processed of [ + [legiscan, openstates], + [openstates, legiscan], + ]) { + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ research: [TWICE_FILED_BILL], processed_bills: processed }) + ); + const [bill] = (await fetchTrackerBills())!; + loads.push([bill.status, bill.legiscanUrl]); + } + + const won = [winner.status, winner.legiscan_url]; + expect(loads).toEqual([won, won]); } - - const later = [TWICE_FILED_LATER.status, TWICE_FILED_LATER.legiscan_url]; - expect(loads).toEqual([later, later]); - }); + ); }); describe('fetchTrackerBills', () => { From 2224463a9b12f32f661579b3709852c1fd9769d4 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:53:31 -0400 Subject: [PATCH 13/19] Report: a bill loses its run only when it scores the law before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billScoresOtherThanWriteUp flagged a bill when any paired provision held no value near the write-up's "after". Correct bills lost "Load full results": a filing-status change written with one status's value, a yearly write-up of a monthly parameter, a step ("+1%") or a percent stored as a whole number. The check now needs evidence of a swap: the named provisions together hold the write-up's "before" value and none of its "after" value. A signed "after" next to an unsigned "before" is a step and is not checked. A stored value also reads as a percent the other way (4.45 for "0.0445"). UT SB60 (0.045 for "4.5% → 4.45%") still flags. changeNamesPath is exported, so the Policy changes table can share it. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/billProvisions.ts | 79 +++++++----- .../unit/libs/flagship/billProvisions.test.ts | 115 +++++++++++++++--- 2 files changed, 151 insertions(+), 43 deletions(-) diff --git a/app/src/libs/flagship/billProvisions.ts b/app/src/libs/flagship/billProvisions.ts index 147c7caa0..6c9469b4f 100644 --- a/app/src/libs/flagship/billProvisions.ts +++ b/app/src/libs/flagship/billProvisions.ts @@ -90,28 +90,38 @@ export function billAlreadyCurrentLaw( ); } +/** A write-up value read as one number, with the rounding its digits allow. */ +interface WriteUpNumber { + value: number; + /** How far the true value can lie from the written one: half its last digit. */ + tolerance: number; + /** Whether the text has its own sign ("+1%", "−0.25%"). */ + signed: boolean; +} + /** - * 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. + * A write-up value that is one plain number — "4.45%", "$2,000", "-0.5". * 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*%?$/); +function writeUpNumber(text: string): WriteUpNumber | undefined { + const match = text.trim().match(/^([-−+]?)\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, + value: sign === '-' || sign === '−' ? -magnitude : magnitude, tolerance: 0.5 * 10 ** -decimals.length, + signed: sign !== '', }; } -/** Whether a change's parameter, which can hold "{filing_status}", names this path. */ -function changeNamesPath(parameter: string, path: string): boolean { +/** + * Whether a tracker change's parameter, which can hold a placeholder + * ("main.{filing_status}.brackets[5].rate"), names this path. + */ +export function changeNamesPath(parameter: string, path: string): boolean { const pattern = parameter .split(/\{[^}]+\}/) .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) @@ -119,18 +129,23 @@ function changeNamesPath(parameter: string, path: string): boolean { return new RegExp(`^${pattern}$`).test(path); } +/** A stored value as a write-up can give it: as stored, or as a percent either way. */ +const writtenForms = (value: number) => [value, value * 100, value / 100]; + /** - * 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. + * Whether a bill's run would score the law before the bill instead of the + * bill: the stored parameters hold the write-up's "before" value and none + * of its "after" value (a tracker row with its reform and baseline + * swapped, as UT SB60's is). * - * 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. + * A change is checked against the provisions it names, all of them + * together (one filing status can hold the written value), or against a + * bill's only provision when the write-up has one change. A change that + * cannot be paired, that is not two plain numbers, or whose "after" is a + * step ("+1%" from "4%") is not checked. A written value agrees with a + * stored one as written or as a percent ("4.45%" is 0.0445), within the + * write-up's rounding. A value in other units ("$1,000" a year for 83.33 + * a month) agrees with neither, so it flags nothing. */ export function billScoresOtherThanWriteUp( provisions: Array>, @@ -140,8 +155,9 @@ export function billScoresOtherThanWriteUp( return false; } return changes.some((change) => { - const written = writeUpNumber(change.after); - if (!written) { + const before = writeUpNumber(change.before); + const after = writeUpNumber(change.after); + if (!before || !after || (after.signed && !before.signed)) { return false; } const described = change.parameter @@ -149,15 +165,20 @@ export function billScoresOtherThanWriteUp( : 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 = [ + const scored = described + .flatMap((provision) => [ 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)); - }); + ]) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + const holds = (written: WriteUpNumber) => + scored.some((value) => + writtenForms(value).some( + (form) => + Math.abs(form - written.value) <= + written.tolerance + 1e-9 * Math.max(1, Math.abs(written.value)) + ) + ); + return holds(before) && !holds(after); }); } diff --git a/app/src/tests/unit/libs/flagship/billProvisions.test.ts b/app/src/tests/unit/libs/flagship/billProvisions.test.ts index 5dbb02cba..6cd90f7f4 100644 --- a/app/src/tests/unit/libs/flagship/billProvisions.test.ts +++ b/app/src/tests/unit/libs/flagship/billProvisions.test.ts @@ -3,6 +3,7 @@ import { billAlreadyCurrentLaw, billReportProvisions, billScoresOtherThanWriteUp, + changeNamesPath, } from '@/libs/flagship/billProvisions'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; @@ -132,6 +133,20 @@ describe('billAlreadyCurrentLaw', () => { }); }); +describe('changeNamesPath', () => { + test('given a placeholder then it stands for one path segment', () => { + const parameter = 'gov.states.ga.tax.income.main.{filing_status}.brackets[5].rate'; + + expect(changeNamesPath(parameter, 'gov.states.ga.tax.income.main.joint.brackets[5].rate')).toBe( + true + ); + expect(changeNamesPath(parameter, 'gov.states.ga.tax.income.main.joint.brackets[4].rate')).toBe( + false + ); + expect(changeNamesPath(RATE, RATE)).toBe(true); + }); +}); + describe('billScoresOtherThanWriteUp', () => { const UT_RATE = 'gov.states.ut.tax.income.rate'; const change = (before: string, after: string, parameter?: string) => ({ @@ -154,44 +169,116 @@ describe('billScoresOtherThanWriteUp', () => { 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 a write-up rounded to its last digit then it still reads the scored value', () => { + // Within the rounding of "4.5%": the law before the bill. + expect( + billScoresOtherThanWriteUp([{ path: UT_RATE, value: 0.04501 }], [change('4.5%', '4.45%')]) + ).toBe(true); + // Within the rounding of "4.4%": the bill. + expect( + billScoresOtherThanWriteUp([{ path: RATE, value: 0.04449 }], [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(provisions, [change('$2,000', '$2,500')])).toBe(true); expect( billScoresOtherThanWriteUp([{ path: UT_RATE, value: 0.0445 }], [change('4.5', '4.45')]) ).toBe(false); }); + test('given a percent stored as a whole number then a write-up as a fraction reads it', () => { + expect( + billScoresOtherThanWriteUp([{ path: UT_RATE, value: 4.45 }], [change('0.045', '0.0445')]) + ).toBe(false); + expect( + billScoresOtherThanWriteUp([{ path: UT_RATE, value: 4.5 }], [change('0.045', '0.0445')]) + ).toBe(true); + }); + 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]), + dated(RATE, ['2026-01-01', '2026-12-31', 0.0519], ['2027-01-01', '2100-12-31', 0.0479]), ]; + // The first year keeps the law before the bill; the bill's value comes later. 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 parameter = 'gov.states.ga.tax.income.main.{filing_status}.brackets[5].rate'; + const swapped = [ + { path: 'gov.states.ga.tax.income.main.single.brackets[5].rate', value: 0.0549 }, + { path: 'gov.states.ga.tax.income.main.joint.brackets[5].rate', value: 0.0549 }, + // Another provision that holds the written value does not count. + { path: DEDUCTION, value: 5.19 }, + ]; + + expect(billScoresOtherThanWriteUp(swapped, [change('5.49%', '5.19%', parameter)])).toBe(true); + }); + + test('given one filing status holds the written value then the bill agrees', () => { + const parameter = 'gov.states.ga.tax.income.main.{filing_status}.brackets[5].rate'; const provisions = [ - { path: 'gov.states.ga.tax.income.main.single.brackets[5].rate', value: 0.05 }, + { path: 'gov.states.ga.tax.income.main.single.brackets[5].rate', value: 0.0519 }, + // The joint rate stays at the written law before the bill. { 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, [change('5.75%', '5.19%', parameter)])).toBe( + false + ); + }); + + test('given a threshold written as the joint amount then the bill agrees', () => { + // A filing-status threshold written up with one value: the joint one. + const parameter = + 'gov.states.ut.tax.income.credits.taxpayer.phase_out.threshold.{filing_status}'; + const provisions = [ + { + path: 'gov.states.ut.tax.income.credits.taxpayer.phase_out.threshold.SINGLE', + value: 250_000, + }, + { + path: 'gov.states.ut.tax.income.credits.taxpayer.phase_out.threshold.JOINT', + value: 500_000, + }, + ]; + + expect( + billScoresOtherThanWriteUp(provisions, [change('$400,000', '$500,000', parameter)]) + ).toBe(false); + // The single amount equals the written law before the bill; the joint one is the bill. + expect( + billScoresOtherThanWriteUp(provisions, [change('$250,000', '$500,000', parameter)]) + ).toBe(false); + }); + + test('given a yearly write-up of a monthly parameter then nothing is flagged', () => { + // $1,000 a year is 83.33 a month; neither written value is the stored one. + const provisions = [{ path: 'gov.states.ut.benefit.monthly_amount', value: 83.33 }]; + + expect(billScoresOtherThanWriteUp(provisions, [change('$500', '$1,000')])).toBe(false); + }); + + test('given an after written as a step or a year then nothing is flagged', () => { + const phased = [ + dated(UT_RATE, ['2026-01-01', '2026-12-31', 0.045], ['2027-01-01', '2100-12-31', 0.0425]), + ]; + + // "−0.25%" is the step from 4.5%, not the new rate. + expect(billScoresOtherThanWriteUp(phased, [change('4.5%', '−0.25%')])).toBe(false); + expect( + billScoresOtherThanWriteUp( + [dated(UT_RATE, ['2026-01-01', '2026-12-31', 0.04], ['2027-01-01', '2100-12-31', 0.05])], + [change('4%', '+1%')] + ) + ).toBe(false); expect( - billScoresOtherThanWriteUp(provisions.slice(0, 1).concat(provisions.slice(2)), [ - change('5.49%', '5%', parameter), - ]) + billScoresOtherThanWriteUp([{ path: UT_RATE, value: 0.0445 }], [change('2026', '2027')]) ).toBe(false); }); From 33a276865d6666454d5c0496a0765046d6b775d0 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:53:51 -0400 Subject: [PATCH 14/19] Reforms: the paged feed test checks each bill's baseline read The 1,001-bill test now gives every reform_impacts row its baseline params and checks that every bill's provisions carry that baseline. Before, the baselines read could lose its row order and the test still passed; now that read drops the 1,001st bill's baseline and the test fails. Co-Authored-By: Claude Opus 5.5 --- app/src/tests/fixtures/api/billFeedMocks.ts | 6 ++++++ app/src/tests/unit/api/billFeed.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 318ae2029..ec2fbf2fb 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -121,6 +121,9 @@ export const LATE_STATUS_ROWS: TrackerTableRows = { /** 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'; +/** The rate each of those bills changes, and the law it was compared with. */ +const MANY_BILLS_RATE_PATH = 'gov.states.ok.tax.income.rate'; +export const MANY_BILLS_BASELINE_RATE = 0.0475; const MANY_BILL_NUMBERS = Array.from({ length: MANY_BILLS }, (_, index) => 1000 + index); @@ -132,10 +135,13 @@ export const MANY_BILL_ROWS: TrackerTableRows = { type: 'bill', title: `OK HB ${number}`, })), + // One row serves both reform_impacts reads: the run and its baseline. reform_impacts: MANY_BILL_NUMBERS.map((number) => ({ id: `ok-hb${number}`, computed: true, budgetary_impact: { stateRevenueImpact: -number * 1000 }, + reform_params: { [MANY_BILLS_RATE_PATH]: 0.04 }, + baseline_params: { [MANY_BILLS_RATE_PATH]: MANY_BILLS_BASELINE_RATE }, })), validation_metadata: MANY_BILL_NUMBERS.map((number) => ({ id: `ok-hb${number}`, diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 725b354bb..c133571ef 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -13,6 +13,7 @@ import { LATE_STATUS_ROWS, MANY_BILL_ROWS, MANY_BILLS, + MANY_BILLS_BASELINE_RATE, MANY_BILLS_STATUS, mockPagedTrackerFetch, mockTrackerFetch, @@ -322,7 +323,7 @@ describe('tracker reads', () => { 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 () => { + test('given every table past one page then each bill reads once, with its run, baseline, 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)); @@ -335,12 +336,16 @@ describe('tracker reads', () => { listed: bills.length, unique: billsWith(() => true), withRun: billsWith((bill) => bill.impacts?.revenue !== undefined), + withBaseline: billsWith( + (bill) => bill.provisions[0]?.baselineIntervals?.[0]?.value === MANY_BILLS_BASELINE_RATE + ), 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, + withBaseline: MANY_BILLS, withCheck: MANY_BILLS, withStage: MANY_BILLS, }); From b45f2d1bcf36311375d239322a2776c7538feeb0 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:54:24 -0400 Subject: [PATCH 15/19] Report: the Policy changes table reads change parameters with the shared helper ReportChanges copied changeNamesPath line by line as namesPath. It now imports the one in billProvisions. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/report/ReportChanges.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index 1f0d68463..45716668a 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -6,6 +6,7 @@ import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { sameParameterValue } from '@/libs/draftReform'; +import { changeNamesPath } from '@/libs/flagship/billProvisions'; import { describeChange, reportProvisionName } from '@/libs/flagship/draftLabels'; import type { RunReportProvision } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; @@ -83,15 +84,6 @@ function provisionDates(provision: Pick): Chang }; } -/** Whether a tracker parameter, which can hold "{filing_status}", names this path. */ -function namesPath(parameter: string, path: string): boolean { - const pattern = parameter - .split(/\{[^}]+\}/) - .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('[^.]+'); - return new RegExp(`^${pattern}$`).test(path); -} - const dateText = (date: string) => dayjs(date).format('MMM D, YYYY'); const endText = (date: string) => (date === FOREVER ? 'No end date' : dateText(date)); @@ -138,7 +130,7 @@ export default function ReportChanges({ : undefined; const changeDates = (change: BillChange): ChangeDates | undefined => { const named = change.parameter - ? provisions.filter((provision) => namesPath(change.parameter!, provision.path)) + ? provisions.filter((provision) => changeNamesPath(change.parameter!, provision.path)) : []; if (named.length > 0) { const dates = named.map(provisionDates); From 7f189d92b9410ca95da0aded3ef60013869c1227 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:55:14 -0400 Subject: [PATCH 16/19] Report: test that a state bill's stored budget reads as state revenue The federal page test passes with the page's state prop removed, since no state already gives the federal words. This test fails then: a Utah bill must read "Annual state revenue loss" on Economic impacts. Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/pages/flagship/BillReport.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index c08ddf1e7..f3ca6de18 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -167,6 +167,21 @@ describe('BillReportPage', () => { expect(budget).not.toHaveTextContent(/state revenue/i); }); + test('given a state bill then economic impacts word its stored budget as state revenue', async () => { + const user = userEvent.setup(); + mockFetchTrackerBills.mockResolvedValue([ + { ...TRACKED_BILL, id: 'ut-hb106', jurisdiction: 'Utah', state: 'UT' }, + ]); + renderReport('ut-hb106'); + + 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 state revenue loss'); + }); + test('given the overview then summary, date and bill text show; the verdict is in Validation', async () => { const user = userEvent.setup(); renderReport(); From 2d94303eaa16ef544cb9cd4832d4cddf5929eb74 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:56:09 -0400 Subject: [PATCH 17/19] Validation: an empty fiscal-note link is no fiscal note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed maps a null fiscal_note_url to undefined but keeps "". With no estimate, an empty link then gave an "Official fiscal note · No estimate" row with no link and no source. The estimates table now treats a blank link as absent. Co-Authored-By: Claude Opus 5.5 --- app/src/components/flagship/ValidationPanel.tsx | 11 +++++------ .../components/flagship/ValidationPanel.test.tsx | 13 +++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index 72b9c985b..27b7e86bd 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -163,9 +163,10 @@ export function BillExternalEstimates({ ? [validation.targetRangeLow, validation.targetRangeHigh].sort((a, b) => a - b) : null; const noteSource = validation.fiscalNoteSource?.trim(); + // An empty stored link is no link. + const noteUrl = validation.fiscalNoteUrl?.trim() || undefined; const noteWithoutEstimate = - typeof validation.fiscalNoteEstimate !== 'number' && - (validation.fiscalNoteUrl !== undefined || Boolean(noteSource)); + typeof validation.fiscalNoteEstimate !== 'number' && Boolean(noteUrl || noteSource); return ( @@ -194,7 +195,7 @@ export function BillExternalEstimates({ {fiscalNoteLabel(validation)} - {validation.fiscalNoteUrl && } + {noteUrl && } No estimate — @@ -205,9 +206,7 @@ export function BillExternalEstimates({ Accepted range {noteWithoutEstimate && noteSource && {noteSource}} - {noteWithoutEstimate && validation.fiscalNoteUrl && ( - - )} + {noteWithoutEstimate && noteUrl && } {compactMoney(range[0])} to {compactMoney(range[1])} diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index 34f83026f..5130d9220 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -145,6 +145,19 @@ describe('BillExternalEstimates', () => { ); }); + test('given an empty fiscal-note link and no estimate then no fiscal-note row shows', () => { + render( + + ); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + expect(within(table).queryByText('Official fiscal note')).not.toBeInTheDocument(); + expect(within(table).queryByText('No estimate')).not.toBeInTheDocument(); + }); + 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( From 040e7edcd854684be0adb7dded41df48a871285e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:58:05 -0400 Subject: [PATCH 18/19] Reforms: the federal draft test checks the bill stays the draft's source The test asserted the draft's year, which draftYear already gave before the fix. It now reads the year the draft runs in, and asserts what the fix changed: the draft opens with source "bill" and no state region. Co-Authored-By: Claude Opus 5.5 --- app/src/tests/unit/pages/flagship/Reforms.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index 7a32fc4b0..db1aed2de 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -2,7 +2,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 { clearDraftReform, draftYear, getDraftReform } from '@/libs/draftReform'; import ReformsPage from '@/pages/flagship/Reforms.page'; import { FEDERAL_ENACTED, @@ -334,9 +334,10 @@ describe('ReformsPage', () => { await user.click(await screen.findByRole('button', { name: /open as draft reform/i })); const draft = getDraftReform(); - expect(draft?.year).toBe(Number(CURRENT_YEAR)); + expect(draftYear(draft)).toBe(Number(CURRENT_YEAR)); + // No state region, and still the bill's draft. expect(draft?.population).toEqual({ scope: 'national' }); - expect(draft?.sourceRef).toBe('us-ctc-expansion'); + expect(draft).toMatchObject({ source: 'bill', sourceRef: 'us-ctc-expansion' }); }); test('given the federal level then state bills leave', async () => { From 78f54f9642ac23ee02659be020baaabf65e99ce6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:58:23 -0400 Subject: [PATCH 19/19] Reforms: a bill whose stored parameters contradict its write-up opens no draft The bill report stopped re-running UT SB60, but on the Reforms page "Open as draft reform" still loaded its swapped 0.045 into Build, and a run there scored the same +$85m increase. The bill panel now uses the same check: "Run impact report" and "Open as draft reform" are disabled, and a note says why. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/flagship/Reforms.page.tsx | 25 ++++++++++-- .../unit/pages/flagship/Reforms.test.tsx | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 09284aaca..ac23dc763 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -41,7 +41,11 @@ import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { useTrackedBills } from '@/hooks/useTrackedBills'; 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 { + billAlreadyCurrentLaw, + billReportProvisions, + billScoresOtherThanWriteUp, +} from '@/libs/flagship/billProvisions'; import { periodLabel, summarizeIntervals } from '@/libs/flagship/draftLabels'; import { RootState } from '@/store'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; @@ -380,6 +384,13 @@ export default function ReformsPage() { }); }; + // As on the bill's report: stored parameters that contradict the bill's + // write-up (UT SB60's reform and baseline are swapped) would score + // another change, so neither a report nor a draft starts from them. + const billScoresOtherChange = (bill: TrackedBill) => + !billAlreadyCurrentLaw(bill.provisions, parameters, billRunYear(bill.provisions)) && + billScoresOtherThanWriteUp(bill.provisions, bill.changes); + /** Current law for a parameter on 1 January of `year`. */ const currentLawAt = (path: string, year: number) => { const values = parameters?.[path]?.values; @@ -536,6 +547,7 @@ export default function ReformsPage() { // ---- Detail: a tracked bill ---- if (selectedBill) { const provisions = billProvisions(selectedBill); + const scoresOtherChange = billScoresOtherChange(selectedBill); const impact = selectedBill.impactData; const winners = impact?.winnersLosers; const betterOff = winners && ((winners.gainMore5Pct ?? 0) + (winners.gainLess5Pct ?? 0)) * 100; @@ -598,6 +610,13 @@ export default function ReformsPage() { unlock when it lands. )} + {scoresOtherChange && ( + + The model parameters the legislative tracker stored for this bill do not match its + write-up of the changes, so a report or draft from them would score a different + change. + + )} {runReport.isRunning ? 'Starting report…' : 'Run impact report'} @@ -634,7 +653,7 @@ export default function ReformsPage() {