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/DeleteReformDialog.tsx b/app/src/components/flagship/DeleteReformDialog.tsx new file mode 100644 index 000000000..e6d7b0cf5 --- /dev/null +++ b/app/src/components/flagship/DeleteReformDialog.tsx @@ -0,0 +1,62 @@ +import { IconTrash } from '@tabler/icons-react'; +import { AlertDialog } from 'radix-ui'; +import { Button } from '@/components/ui'; + +/** What the delete does to the reports run from the reform. */ +function reportsNote(reportCount: number): string { + if (reportCount === 0) { + return ''; + } + return reportCount === 1 + ? ' Its 1 report leaves this list; it still opens from its link.' + : ` Its ${reportCount} reports leave this list; they still open from their links.`; +} + +/** + * The reform detail's Delete button. A delete cannot be undone, and the + * reform's reports are listed only under it, so the button asks first. + */ +export default function DeleteReformDialog({ + name, + reportCount, + disabled, + onConfirm, +}: { + name: string; + /** The reports this browser ran from the reform. */ + reportCount: number; + disabled?: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + + + Delete “{name}”? + + + This removes the reform for good.{reportsNote(reportCount)} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index bba85bdb9..666174a7d 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { IconArrowLeft, IconCheck, @@ -315,6 +315,9 @@ export default function ParameterSearchBox({ const [query, setQuery] = useState(''); const inputRef = useRef(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(null); /** * A folder opened from the results: the dropdown shows everything the @@ -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 && @@ -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, @@ -736,7 +753,7 @@ export default function ParameterSearchBox({ )} - {!hideResults && (browsing || (flatEntries.length > 0 && !widerOnly)) && ( + {resultsShown && (
(
@@ -154,8 +142,17 @@ function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { * a bill, in the same chart cards as a report's economic impacts. Shown * while the full results calculate, or instead of them when they cannot. */ -export default function StoredImpactCharts({ impact }: { impact: TrackedBill['impactData'] }) { +export default function StoredImpactCharts({ + bill, + countryId = 'us', +}: { + bill: StoredBill; + countryId?: Parameters[1]; +}) { const [decileMode, setDecileMode] = useState<'average' | 'relative'>('average'); + const impact = bill.impactData; + // Worded as the overview words it, so the two read the same. + const revenue = storedRevenueMetric(bill, countryId); const average = decileRows(impact?.decile?.average); const relative = decileRows(impact?.decile?.relative, 100); @@ -189,9 +186,8 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im return change === null ? [] : [{ group, change }]; }); - const hasBudget = typeof storedRevenue(impact?.budgetary) === 'number'; if ( - !hasBudget && + !revenue && average.length + relative.length + winnersRows.length + povertyRows.length === 0 ) { return null; @@ -206,7 +202,7 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im }} > {/* The budget leads, as it does in the full results. */} - + {(average.length > 0 || relative.length > 0) && ( + compact && name === 'Working-age adults' ? 'Adults' : name; + const categoryAxis = getCategoryAxis( + chartData.map((d) => categoryLabel(d.name)), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,9 +177,10 @@ export default function DeepPovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + interval={0} + tick={categoryAxis.tick} + height={categoryAxis.height} + tickFormatter={categoryLabel} /> d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +175,7 @@ export default function DeepPovertyImpactByGenderSubPage({ {probe} - + d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals to write every tick exactly. const tickDigits = tickDecimals(yTicks.map((v) => v * 100)); @@ -155,7 +162,7 @@ export default function InequalityImpactSubPage({ {probe} - + + compact && name === 'Working-age adults' ? 'Adults' : name; + const categoryAxis = getCategoryAxis( + chartData.map((d) => categoryLabel(d.name)), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -167,9 +176,10 @@ export default function PovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + interval={0} + tick={categoryAxis.tick} + height={categoryAxis.height} + tickFormatter={categoryLabel} /> d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +175,7 @@ export default function PovertyImpactByGenderSubPage({ {probe} - + d.name), + plotWidth, + RECHARTS_FONT_STYLE + ); // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -165,7 +172,7 @@ export default function PovertyImpactByRaceSubPage({ {probe} - + datum.name), + plotWidth, + RECHARTS_FONT_STYLE + ); const decimals = tickDecimals(ticks); const tickFormatter = (value: number) => formatTick(value, decimals); const yAxisTitle = fitAxisTitle(yAxisTitles, plotHeight); @@ -128,7 +135,9 @@ export default function ReportWaterfallChart({ diff --git a/app/src/components/flagship/report/charts/barLayout.ts b/app/src/components/flagship/report/charts/barLayout.ts index 68d6f7763..a27159532 100644 --- a/app/src/components/flagship/report/charts/barLayout.ts +++ b/app/src/components/flagship/report/charts/barLayout.ts @@ -447,3 +447,76 @@ export function fitAxisTitle( ): string | undefined { return candidates.find((title) => measure(title, VALUE_LABEL_FONT_SIZE) <= plotHeight); } + +// --------------------------------------------------------------------------- +// Category axis +// --------------------------------------------------------------------------- + +/** Clear space kept between two category labels side by side, px. */ +const CATEGORY_LABEL_SIDE_GAP = 4; +/** From the x-axis line to a category label's first line: Recharts' tick size and margin, px. */ +const CATEGORY_LABEL_OFFSET = 8; +/** Room under a category label's last line for its descenders, px. */ +const CATEGORY_LABEL_DESCENT = 2; + +/** The lines a label wraps to, by word, in `width` px. A word wider than that keeps its own line. */ +export function wrapLabel( + label: string, + width: number, + fontSize: number, + measure: MeasureText = measureTextWidth +): string[] { + const lines: string[] = []; + for (const word of label.split(/\s+/).filter(Boolean)) { + const last = lines[lines.length - 1]; + if (last !== undefined && measure(`${last} ${word}`, fontSize) <= width) { + lines[lines.length - 1] = `${last} ${word}`; + } else { + lines.push(word); + } + } + return lines; +} + +export interface CategoryAxis