Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
9ae8107
Bills: tracker pages read in a stable row order
PavelMakarchuk Oct 14, 2026
433382e
Report: a shared link describes the policy changes in the report's year
PavelMakarchuk Oct 14, 2026
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
5704ff0
Report: a federal bill's stored budget is not called state revenue
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
eb5314f
Reforms: deleting a saved reform asks first and reports a failure
PavelMakarchuk Oct 14, 2026
cab31f9
Validation: the fiscal note names its source and links it
PavelMakarchuk Oct 14, 2026
0a16f36
Report: a shared link rebuilds the reform in the report's year, with …
PavelMakarchuk Oct 14, 2026
218b628
Report: a group with no baseline rate reads n/a, not NaN% on the All bar
PavelMakarchuk Oct 14, 2026
5dcbab8
Report: the adjust panel shows a shared link's reform once it loads
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
37ef7c6
Report: the winners figure counts people, and shows losers when they …
PavelMakarchuk Oct 14, 2026
3bbb491
Report: a recompute applies an edited value from the report's year
PavelMakarchuk Oct 14, 2026
bc8f0ce
Report: a recompute reuses a saved reform only when its dates match
PavelMakarchuk Oct 14, 2026
68210dc
Report: every bar keeps its category label at phone width
PavelMakarchuk Oct 14, 2026
11719bb
Report: a household report id says it cannot open, and is never run
PavelMakarchuk Oct 14, 2026
e1b8624
Report: the district map is one named image, not 437 tab stops
PavelMakarchuk Oct 14, 2026
78ab5cd
Bills: leave the tracker row order to #1277
PavelMakarchuk Oct 15, 2026
67752fb
Report: the winners download keeps the All row and the legend
PavelMakarchuk Oct 15, 2026
4a92e12
Build: the parameter search names the highlighted result to screen re…
PavelMakarchuk Oct 15, 2026
564383a
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
PavelMakarchuk Oct 15, 2026
2e03ccd
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
PavelMakarchuk Oct 15, 2026
1d9ad46
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
PavelMakarchuk Oct 15, 2026
c59f7d3
Report: a complete report opens from its stored results
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
e4d0964
Report: every bar keeps its name on a phone
PavelMakarchuk Oct 15, 2026
091731a
Report: a failed run says so on its record, so Your reforms shows Failed
PavelMakarchuk Oct 15, 2026
835e255
Reforms: a bill held twice keeps the row with the later action
PavelMakarchuk Oct 15, 2026
98dd81b
Report: the district view tells small changes apart
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
3829291
Reforms: a report just run is listed under its reform at once
PavelMakarchuk Oct 15, 2026
90443cf
Merge remote-tracking branch 'origin/fix/v3-relevant-comparisons' int…
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
ae32545
Merge pull request #1274 from PolicyEngine/fix/v3-relevant-comparisons
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
5e14e63
Merge remote-tracking branch 'origin/main' into tmp/fix/v3-audit-stat…
PavelMakarchuk Oct 15, 2026
a4b97ac
Merge remote-tracking branch 'origin/main' into tmp/fix/v3-audit-repo…
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
9004535
Merge remote-tracking branch 'origin/main' into tmp/rl
PavelMakarchuk Oct 15, 2026
ade72f8
Merge remote-tracking branch 'origin/fix/v3-audit-states-a11y' into t…
PavelMakarchuk Oct 15, 2026
b395029
Merge remote-tracking branch 'origin/fix/v3-audit-report-content' int…
PavelMakarchuk Oct 15, 2026
8172f5c
Merge remote-tracking branch 'origin/fix/v3-audit-run-lifecycle' into…
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
62 changes: 62 additions & 0 deletions app/src/components/flagship/DeleteReformDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { IconTrash } from '@tabler/icons-react';
import { AlertDialog } from 'radix-ui';
import { Button } from '@/components/ui';

/** What the delete does to the reports run from the reform. */
function reportsNote(reportCount: number): string {
if (reportCount === 0) {
return '';
}
return reportCount === 1
? ' Its 1 report leaves this list; it still opens from its link.'
: ` Its ${reportCount} reports leave this list; they still open from their links.`;
}

/**
* The reform detail's Delete button. A delete cannot be undone, and the
* reform's reports are listed only under it, so the button asks first.
*/
export default function DeleteReformDialog({
name,
reportCount,
disabled,
onConfirm,
}: {
name: string;
/** The reports this browser ran from the reform. */
reportCount: number;
disabled?: boolean;
onConfirm: () => void;
}) {
return (
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<Button variant="outline" disabled={disabled}>
<IconTrash size={16} />
Delete
</Button>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="tw:fixed tw:inset-0 tw:z-50 tw:bg-black/50" />
<AlertDialog.Content className="tw:bg-background tw:fixed tw:top-[50%] tw:left-[50%] tw:z-50 tw:grid tw:w-full tw:max-w-[calc(100%-2rem)] tw:translate-x-[-50%] tw:translate-y-[-50%] tw:gap-4 tw:rounded-lg tw:border tw:p-6 tw:shadow-lg tw:outline-none tw:sm:max-w-[28rem]">
<AlertDialog.Title className="tw:text-lg tw:leading-snug tw:font-semibold">
Delete “{name}”?
</AlertDialog.Title>
<AlertDialog.Description className="tw:text-muted-foreground tw:text-sm">
This removes the reform for good.{reportsNote(reportCount)}
</AlertDialog.Description>
<div className="tw:flex tw:flex-col-reverse tw:gap-2 tw:sm:flex-row tw:sm:justify-end">
<AlertDialog.Cancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button variant="destructive" onClick={onConfirm}>
Delete reform
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}
23 changes: 21 additions & 2 deletions app/src/components/flagship/ParameterSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import {
IconArrowLeft,
IconCheck,
Expand Down Expand Up @@ -315,6 +315,9 @@ export default function ParameterSearchBox({
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [highlighted, setHighlighted] = useState(0);
// Option ids, so the input can name the highlighted row while focus stays in it.
const optionIdPrefix = useId();
const optionId = (index: number) => `${optionIdPrefix}-option-${index}`;
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
/**
* A folder opened from the results: the dropdown shows everything the
Expand Down Expand Up @@ -475,6 +478,18 @@ export default function ParameterSearchBox({

let runningIndex = -1;

const resultsShown =
!hideResults && (browsing !== null || (flatEntries.length > 0 && !widerOnly));
// The row the arrow keys point at, for screen readers: none while a
// folder is pointed at or the list is out of date.
const activeOptionId =
resultsShown &&
!(!browsing && search.stale) &&
highlighted >= 0 &&
highlighted < flatEntries.length
? optionId(highlighted)
: undefined;

const active = query.trim().length > 0 || browsing !== null;
const noMatches =
!hideResults &&
Expand Down Expand Up @@ -559,6 +574,8 @@ export default function ParameterSearchBox({
role="combobox"
aria-expanded={Boolean(browsing) || flatEntries.length > 0}
aria-controls="parameter-search-results"
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
style={{
flex: 1,
minWidth: 0,
Expand Down Expand Up @@ -736,7 +753,7 @@ export default function ParameterSearchBox({
</div>
)}

{!hideResults && (browsing || (flatEntries.length > 0 && !widerOnly)) && (
{resultsShown && (
<div
id="parameter-search-results"
role="listbox"
Expand Down Expand Up @@ -885,6 +902,7 @@ export default function ParameterSearchBox({
{(folderView?.direct ?? []).map((entry, i) => (
<button
key={entry.path}
id={optionId(i)}
type="button"
role="option"
aria-selected={i === highlighted}
Expand Down Expand Up @@ -1130,6 +1148,7 @@ export default function ParameterSearchBox({
return (
<button
key={entry.path}
id={optionId(i)}
type="button"
role="option"
aria-selected={i === highlighted}
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/flagship/ReformReports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Badge, Button, Stack, Text } from '@/components/ui';
import { useAppNavigate } from '@/contexts/NavigationContext';
import { colors, spacing, typography } from '@/designTokens';
import type { CountryId } from '@/libs/countries';
import { readReportMeta } from '@/libs/flagship/runReport';
import { flagshipReportQueryKeys, readReportMeta } from '@/libs/flagship/runReport';

/**
* Where a report stands, as the API's record says. A report whose page
Expand All @@ -17,7 +17,7 @@ import { readReportMeta } from '@/libs/flagship/runReport';
*/
function ReportStatus({ countryId, apiReportId }: { countryId: CountryId; apiReportId: string }) {
const { data } = useQuery({
queryKey: ['flagship-report-status', countryId, apiReportId],
queryKey: flagshipReportQueryKeys.status(countryId, apiReportId),
queryFn: () => fetchReportById(countryId, apiReportId),
staleTime: 60_000,
retry: false,
Expand Down
Loading
Loading