diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..cf82fe144 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; @@ -560,11 +568,74 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; +/** 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, + bill.last_action_date, + ]); + +const ascending = (a: number | string, b: number | string) => (a < b ? -1 : a > b ? 1 : 0); + +/** + * 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 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)) + ); +} + +/** + * 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, @@ -581,11 +652,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]); } - return byKey.get(processedBillKey(state, parts.slice(0, -1).join(''))); + for (const billNumber of candidates) { + const row = byKey.get(processedBillKey(state, billNumber)); + if (row) { + return row; + } + } + return undefined; } /** @@ -595,19 +679,25 @@ 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,last_action_date', + 'bill_id' ), - 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' + '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; @@ -621,12 +711,15 @@ 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, - ]) - ); + // 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/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index 1e24efaa9..27b7e86bd 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,11 @@ 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(); + // An empty stored link is no link. + const noteUrl = validation.fiscalNoteUrl?.trim() || undefined; + const noteWithoutEstimate = + typeof validation.fiscalNoteEstimate !== 'number' && Boolean(noteUrl || noteSource); return ( @@ -157,31 +181,32 @@ 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)} + {noteUrl && } + + No estimate + — + + )} {range && ( - Accepted range + Accepted range + {noteWithoutEstimate && noteSource && {noteSource}} + {noteWithoutEstimate && noteUrl && } {compactMoney(range[0])} to {compactMoney(range[1])} 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); 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) && ( ({ ...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/libs/flagship/billProvisions.ts b/app/src/libs/flagship/billProvisions.ts index 6ad2d081d..6c9469b4f 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,96 @@ 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". + * Anything else (words, ranges, several values) reads as nothing. + */ +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 === '−' ? -magnitude : magnitude, + tolerance: 0.5 * 10 ** -decimals.length, + signed: sign !== '', + }; +} + +/** + * 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, '\\$&')) + .join('[^.]+'); + 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 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). + * + * 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>, + changes: BillChange[] | undefined +): boolean { + if (!changes || changes.length === 0) { + return false; + } + return changes.some((change) => { + const before = writeUpNumber(change.before); + const after = writeUpNumber(change.after); + if (!before || !after || (after.signed && !before.signed)) { + return false; + } + const described = change.parameter + ? provisions.filter((provision) => changeNamesPath(change.parameter!, provision.path)) + : provisions.length === 1 && changes.length === 1 + ? provisions + : []; + const scored = described + .flatMap((provision) => [ + provision.value, + ...(provision.intervals ?? []).map((interval) => interval.value), + ]) + .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/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/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 29fe2674c..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( @@ -322,7 +341,7 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) economyPending={ - + } districtsPending={ diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4667dccfb..ac23dc763 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -39,14 +39,13 @@ 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 { + 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'; @@ -385,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; @@ -470,11 +476,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`); }; @@ -538,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; @@ -600,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'} @@ -636,7 +653,7 @@ export default function ReformsPage() {