Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2e15b9f
Reforms: the tracker feed reads every bill-status row once
PavelMakarchuk Oct 14, 2026
303fbf3
Reforms: bills join their stage by the bill number in the title
PavelMakarchuk Oct 14, 2026
e5f7b37
Reforms: the tracker feed reads its pages in a fixed row order
PavelMakarchuk Oct 14, 2026
075a4d7
Reforms: a bill opened as a draft keeps its run year and state
PavelMakarchuk Oct 14, 2026
9984e07
Reforms: a bill filed twice under one number keeps the later bill's s…
PavelMakarchuk Oct 14, 2026
1ecbe59
Report: a federal bill's stored budget is not called state revenue
PavelMakarchuk Oct 14, 2026
cab31f9
Validation: the fiscal note names its source and links it
PavelMakarchuk Oct 14, 2026
f721182
Report: a bill whose stored parameters contradict its write-up is not…
PavelMakarchuk Oct 14, 2026
e36a199
Reforms: format the bill feed and validation changes
PavelMakarchuk Oct 14, 2026
1d9ad46
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
PavelMakarchuk Oct 15, 2026
bbd0020
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
PavelMakarchuk Oct 15, 2026
a1146d2
Reforms: leave the tracker row order to the security fix
PavelMakarchuk Oct 15, 2026
e811d54
Reforms: test a bill that only its title joins to its stage
PavelMakarchuk Oct 15, 2026
835e255
Reforms: a bill held twice keeps the row with the later action
PavelMakarchuk Oct 15, 2026
2224463
Report: a bill loses its run only when it scores the law before it
PavelMakarchuk Oct 15, 2026
33a2768
Reforms: the paged feed test checks each bill's baseline read
PavelMakarchuk Oct 15, 2026
b45f2d1
Report: the Policy changes table reads change parameters with the sha…
PavelMakarchuk Oct 15, 2026
7f189d9
Report: test that a state bill's stored budget reads as state revenue
PavelMakarchuk Oct 15, 2026
2d94303
Validation: an empty fiscal-note link is no fiscal note
PavelMakarchuk Oct 15, 2026
040e7ed
Reforms: the federal draft test checks the bill stays the draft's source
PavelMakarchuk Oct 15, 2026
78f54f9
Reforms: a bill whose stored parameters contradict its write-up opens…
PavelMakarchuk Oct 15, 2026
4e413aa
Merge remote-tracking branch 'origin/main' into tmp/fix/v3-audit-secu…
PavelMakarchuk Oct 15, 2026
ac1b7a0
Merge remote-tracking branch 'origin/main' into tmp/fix/v3-audit-bills
PavelMakarchuk Oct 15, 2026
ba9a650
Merge branch 'tmp/fix/v3-audit-security' into tmp/bills-on-1277
PavelMakarchuk Oct 15, 2026
73fce16
Merge commit '78f54f96' into tmp/bills2
PavelMakarchuk Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 109 additions & 16 deletions app/src/api/billFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[] | null> {
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand All @@ -595,19 +679,25 @@ function processedBillFor(
export async function fetchTrackerBills(): Promise<TrackedBill[] | null> {
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;
Expand All @@ -621,12 +711,15 @@ export async function fetchTrackerBills(): Promise<TrackedBill[] | null> {
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<string, any>();
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))
Expand Down
55 changes: 40 additions & 15 deletions app/src/components/flagship/ValidationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -124,9 +125,27 @@ const VERIFICATION_COPY: Record<NonNullable<BillValidation['verification']>['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 (
<CellNote>
<a
href={href}
target="_blank"
rel="noreferrer"
style={{ ...linkStyle, fontSize: typography.fontSize.xs }}
>
Read source
</a>
</CellNote>
);
}

/**
* 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,
Expand All @@ -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 (
<Stack style={{ gap: spacing.md }}>
<EvidenceTable label="Estimates for this bill" columns={['Source', 'Estimate', 'Gap']}>
Expand All @@ -157,31 +181,32 @@ export function BillExternalEstimates({
<td style={numberCellStyle}>—</td>
</tr>
)}
{claims.map((claim) => (
<tr key={claim.source} style={rowStyle}>
{claims.map((claim, index) => (
<tr key={`${index}-${claim.source}`} style={rowStyle}>
<th scope="row" style={labelCellStyle}>
<span>{claim.source}</span>
{claim.sourceUrl && (
<CellNote>
<a
href={claim.sourceUrl}
target="_blank"
rel="noreferrer"
style={{ ...linkStyle, fontSize: typography.fontSize.xs }}
>
Read source
</a>
</CellNote>
)}
{claim.sourceUrl && <ReadSource href={claim.sourceUrl} />}
</th>
<td style={numberCellStyle}>{compactMoney(claim.externalValue)}</td>
<td style={numberCellStyle}>{gapText(peEstimate, claim.externalValue)}</td>
</tr>
))}
{noteWithoutEstimate && !range && (
<tr style={rowStyle}>
<th scope="row" style={labelCellStyle}>
<span>{fiscalNoteLabel(validation)}</span>
{noteUrl && <ReadSource href={noteUrl} />}
</th>
<td style={numberCellStyle}>No estimate</td>
<td style={numberCellStyle}>—</td>
</tr>
)}
{range && (
<tr style={rowStyle}>
<th scope="row" style={labelCellStyle}>
Accepted range
<span>Accepted range</span>
{noteWithoutEstimate && noteSource && <CellNote>{noteSource}</CellNote>}
{noteWithoutEstimate && noteUrl && <ReadSource href={noteUrl} />}
</th>
<td style={numberCellStyle}>
{compactMoney(range[0])} to {compactMoney(range[1])}
Expand Down
12 changes: 2 additions & 10 deletions app/src/components/flagship/report/ReportChanges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,15 +84,6 @@ function provisionDates(provision: Pick<RunReportProvision, 'intervals'>): 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));

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 16 additions & 11 deletions app/src/components/flagship/report/StoredImpactCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section
aria-label="Budgetary impact"
Expand Down Expand Up @@ -154,7 +152,14 @@ function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) {
* a bill, in the same chart cards as a report's economic impacts. Shown
* while the full results calculate, or instead of them when they cannot.
*/
export default function StoredImpactCharts({ impact }: { impact: TrackedBill['impactData'] }) {
export default function StoredImpactCharts({
impact,
state,
}: {
impact: TrackedBill['impactData'];
/** The bill's state; absent for a federal bill. */
state?: TrackedBill['state'];
}) {
const [decileMode, setDecileMode] = useState<'average' | 'relative'>('average');

const average = decileRows(impact?.decile?.average);
Expand Down Expand Up @@ -206,7 +211,7 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im
}}
>
{/* The budget leads, as it does in the full results. */}
<StoredBudget budgetary={impact?.budgetary} />
<StoredBudget budgetary={impact?.budgetary} state={state} />

{(average.length > 0 || relative.length > 0) && (
<ReportChartCard
Expand Down
Loading
Loading