diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 8391c3538..c9f10b12f 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -1,4 +1,6 @@ +import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; +import type { ValueInterval } from '@/types/subIngredients/valueInterval'; /** * The bill feed behind the Reforms surface. @@ -11,11 +13,42 @@ import { CountryId } from '@/libs/countries'; */ export interface TrackedBillProvision { path: string; + /** The value in effect when the bill's run year starts (see billRunYear). */ value: any; + /** Every dated value the bill sets, as scored; absent for an undated value. */ + intervals?: ValueInterval[]; + /** + * The law the tracker compared the bill with, by the same dates + * (reform_impacts.baseline_params), when it stored it. Once a bill is + * law, today's values include it, so only this says what it changed. + */ + baselineIntervals?: ValueInterval[]; /** Used when the parameter is missing from loaded metadata. */ fallbackBreadcrumb?: string; } +/** + * One change as the tracker described it when it scored the bill: what + * the law was then and what the bill makes it. Once a bill is enacted, + * today's parameter values already include it, so only these say what + * it changed. + */ +export interface BillChange { + label: string; + /** The law before the bill, as the tracker wrote it, e.g. "5.09%". */ + before: string; + /** The bill's value, e.g. "4.99%" or "Under 7 (2026) to Under 16 (2035)". */ + after: string; + explanation?: string; + /** Where in the bill, e.g. "Section 1, amending C.G.S. § 12-700". */ + section?: string; + /** + * The model parameter the change sets, when the tracker named it; it + * can hold a placeholder ("main.{filing_status}.brackets[7].rate"). + */ + parameter?: string; +} + /** Shares of households by outcome, as fractions of 1. */ export interface WinnerShares { gainMore5Pct?: number; @@ -74,6 +107,8 @@ export interface TrackedBill { status: string; summary: string; provisions: TrackedBillProvision[]; + /** The changes as scored, before and after, when the tracker wrote them up. */ + changes?: BillChange[]; /** Headline findings from the tracker's analysis, when available. */ keyFindings?: string[]; legiscanUrl?: string; @@ -133,12 +168,18 @@ function trackerConfig(): { url: string; anonKey: string } | null { return url && anonKey ? { url, anonKey } : null; } -async function trackerSelect(table: string, select: string): Promise { +async function trackerSelect( + table: string, + select: string, + /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ + filter?: string +): Promise { const config = trackerConfig(); if (!config) { return null; } - const response = await fetch(`${config.url}/rest/v1/${table}?select=${select}`, { + const query = `select=${select}${filter ? `&${filter}` : ''}`; + const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, Authorization: `Bearer ${config.anonKey}`, @@ -204,15 +245,153 @@ const STATE_NAMES: Record = { WY: 'Wyoming', }; -/** Reform-params dict ({path: {"date.date": value}} or {path: value}) → provisions. */ -export function provisionsFromReformParams(reformParams: unknown): TrackedBillProvision[] { +/** + * A tracker period key as dates: "2026-01-01.2026-12-31", a lone start + * date, or a bare year ("2026", which the tracker reads as from Jan 1). + */ +function periodDates(key: string): { startDate: string; endDate: string } { + const [start, endDate] = key.split('.'); + return { + startDate: /^\d{4}$/.test(start) ? `${start}-01-01` : start, + endDate: endDate || FOREVER, + }; +} + +/** The value a set of dated values holds on a date, if any covers it. */ +export function intervalValueAt(intervals: ValueInterval[], date: string): unknown { + return intervals.find((interval) => interval.startDate <= date && date <= interval.endDate) + ?.value; +} + +/** + * The year a bill's full run scores: this year, or the year the bill + * first takes effect when that is later — a bill from 2027 changes + * nothing in 2026. + */ +export function billRunYear(provisions: Array>): number { + const starts = provisions.flatMap((provision) => + (provision.intervals ?? []).map((interval) => Number(interval.startDate.slice(0, 4))) + ); + return Math.max(Number(CURRENT_YEAR), starts.length > 0 ? Math.min(...starts) : 0); +} + +/** A parameter path as the model's metadata and the API name it. */ +const modelPath = (path: string) => path.replace(/\.brackets\[/g, '['); + +/** A tracker params value ({"date.date": value} or a bare value) as dated values. */ +function datedValues(raw: unknown): ValueInterval[] | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + return Object.entries(raw) + .map(([key, value]) => ({ ...periodDates(key), value: value as any })) + .sort((a, b) => a.startDate.localeCompare(b.startDate)); +} + +/** + * Reform-params dict ({path: {"date.date": value}} or {path: value}) → + * provisions, each with every dated value it sets. Keys starting with "_" + * are the tracker's run settings ("_use_reform"), not parameters, and + * scale paths lose the tracker's ".brackets" ("rates.brackets[0].rate" + * is "rates[0].rate" in the model's metadata and the API). The baseline + * params, in the same shape, are the law the tracker compared with. + */ +export function provisionsFromReformParams( + reformParams: unknown, + baselineParams?: unknown +): TrackedBillProvision[] { if (!reformParams || typeof reformParams !== 'object') { return []; } - return Object.entries(reformParams as Record).map(([path, raw]) => { - const value = - raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.values(raw)[0] : raw; - return { path, value }; + const params = reformParams as Record; + const baselines = new Map( + baselineParams && typeof baselineParams === 'object' + ? Object.entries(baselineParams as Record).map(([path, raw]) => [ + modelPath(path), + datedValues(raw) ?? [ + { startDate: `${CURRENT_YEAR}-01-01`, endDate: FOREVER, value: raw as any }, + ], + ]) + : [] + ); + const parsed: TrackedBillProvision[] = Object.entries(params) + .filter(([path]) => !path.startsWith('_')) + .map(([path, raw]) => { + const intervals = datedValues(raw); + const baselineIntervals = baselines.get(modelPath(path)); + return { + path: modelPath(path), + value: intervals ? intervals[0]?.value : raw, + ...(intervals ? { intervals } : {}), + ...(baselineIntervals ? { baselineIntervals } : {}), + }; + }); + // The tracker can turn a bill's model change on by name ("_use_reform": + // "ut_hb210"); the API turns it on with its switch, so add the switch + // when the reform leaves it out. + const reformName = typeof params._use_reform === 'string' ? params._use_reform : ''; + const [, state, bill] = reformName.match(/^([a-z]{2})_([a-z0-9_]+)$/) ?? []; + if (state && !parsed.some((provision) => provision.path.endsWith('.in_effect'))) { + const starts = parsed.flatMap((provision) => provision.intervals ?? []); + const startDate = starts.reduce( + (earliest, interval) => (interval.startDate < earliest ? interval.startDate : earliest), + `${CURRENT_YEAR}-01-01` + ); + parsed.push({ + path: `gov.contrib.states.${state}.${bill}.in_effect`, + value: true, + intervals: [{ startDate, endDate: FOREVER, value: true }], + }); + } + const runStart = `${billRunYear(parsed)}-01-01`; + return parsed.map((provision) => { + if (!provision.intervals) { + return provision; + } + const inRunYear = intervalValueAt(provision.intervals, runStart); + return { ...provision, value: inRunYear === undefined ? provision.value : inRunYear }; + }); +} + +/** + * The tracker's write-up of a bill's changes (reform_impacts.provisions: + * [{label, baseline, reform, explanation, bill_section}], stored as a list or as its + * JSON) → the changes it scored. Entries missing a label or a value drop. + */ +export function changesFromTrackerProvisions(raw: unknown): BillChange[] { + let list = raw; + if (typeof raw === 'string') { + try { + list = JSON.parse(raw); + } catch { + return []; + } + } + if (!Array.isArray(list)) { + return []; + } + const text = (value: unknown) => + typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''; + return list.flatMap((entry): BillChange[] => { + const label = text(entry?.label); + const before = text(entry?.baseline); + const after = text(entry?.reform); + if (!label || !before || !after) { + return []; + } + const explanation = text(entry?.explanation); + const section = text(entry?.bill_section); + const parameter = text(entry?.parameter); + return [ + { + label, + before, + after, + ...(explanation ? { explanation } : {}), + ...(section ? { section } : {}), + ...(parameter ? { parameter: modelPath(parameter) } : {}), + }, + ]; }); } @@ -265,28 +444,100 @@ function extractImpactData(impact: any): TrackedBill['impactData'] { return Object.keys(data).length > 0 ? data : undefined; } +/** One district's stored result, in the API's district-breakdown shape. */ +export interface StoredDistrictImpact { + /** "KS-01", as the API and the district map name it. */ + district: string; + average_household_income_change: number; + winner_percentage?: number; + loser_percentage?: number; +} + +/** + * The tracker's stored congressional-district results for one bill + * (reform_impacts.district_impacts: {"KS-1": {avgBenefit, winnersShare, + * losersShare, ...}}). Fetched for a single bill, as its report opens: + * every bill's districts would weigh down the whole feed. Null when the + * feed is not configured or the bill has none. + */ +export async function fetchTrackerBillDistricts( + billId: string +): Promise { + const rows = await trackerSelect( + 'reform_impacts', + 'district_impacts', + `id=eq.${encodeURIComponent(billId)}` + ); + const raw = rows?.[0]?.district_impacts; + if (!raw || typeof raw !== 'object') { + return null; + } + const districts = Object.entries(raw as Record).flatMap( + ([key, entry]): StoredDistrictImpact[] => { + const [state, number] = key.split('-'); + if (!state || !/^\d+$/.test(number ?? '') || typeof entry?.avgBenefit !== 'number') { + return []; + } + return [ + { + district: `${state.toUpperCase()}-${number.padStart(2, '0')}`, + average_household_income_change: entry.avgBenefit, + ...(typeof entry.winnersShare === 'number' + ? { winner_percentage: entry.winnersShare } + : {}), + ...(typeof entry.losersShare === 'number' ? { loser_percentage: entry.losersShare } : {}), + }, + ]; + } + ); + return districts.length > 0 ? districts : null; +} + +/** + * Whether a tracker research entry is a reform the Reforms tab can show: + * a bill, or a blog post that carries a reform to score. Dashboards and + * tools (the OBBBA Household Explorer, MyFriendBen) are apps, and a blog + * post without reform params is prose only. + */ +function isScorableReform(record: any, impact: any): boolean { + if (!record.type || record.type === 'bill') { + return true; + } + const params = impact?.reform_params; + return ( + record.type === 'blog' && + !!params && + typeof params === 'object' && + Object.keys(params).length > 0 + ); +} + /** * Fetches analyzed bills from the tracker's Supabase project. Returns * null when the feed is not configured (callers fall back to samples). */ export async function fetchTrackerBills(): Promise { - const [research, impacts, processed, validations] = await Promise.all([ + const [research, impacts, processed, validations, baselines] = await Promise.all([ trackerSelect('research', '*'), trackerSelect( 'reform_impacts', - 'id,reform_params,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' ), trackerSelect('processed_bills', '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' ), + // 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), ]); if (!research) { return null; } 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])); const processedByKey = new Map( (processed ?? []).map((bill) => [ @@ -295,40 +546,43 @@ export async function fetchTrackerBills(): Promise { ]) ); - return research.map((record): TrackedBill => { - const impact = impactsById.get(record.id); - const status = processedByKey.get(record.id)?.status; - return { - id: record.id, - countryId: 'us', - jurisdiction: STATE_NAMES[record.state] ?? record.state ?? 'Federal', - // The tracker files federal bills under "US", which is not a state. - state: - /^[a-z]{2}$/i.test(record.state ?? '') && record.state.toUpperCase() !== 'US' - ? record.state.toUpperCase() + return research + .filter((record) => isScorableReform(record, impactsById.get(record.id))) + .map((record): TrackedBill => { + const impact = impactsById.get(record.id); + const status = processedByKey.get(record.id)?.status; + return { + id: record.id, + countryId: 'us', + jurisdiction: STATE_NAMES[record.state] ?? record.state ?? 'Federal', + // The tracker files federal bills under "US", which is not a state. + state: + /^[a-z]{2}$/i.test(record.state ?? '') && record.state.toUpperCase() !== 'US' + ? record.state.toUpperCase() + : undefined, + title: record.title ?? record.id, + status: status || 'Analyzed', + summary: record.description ?? '', + provisions: provisionsFromReformParams(impact?.reform_params, baselineById.get(record.id)), + changes: changesFromTrackerProvisions(impact?.provisions), + keyFindings: Array.isArray(record.key_findings) ? record.key_findings : undefined, + legiscanUrl: processedByKey.get(record.id)?.legiscan_url ?? undefined, + sourceUrl: record.url ?? undefined, + author: record.author ?? undefined, + date: record.date ?? undefined, + provenance: impact + ? { + modelVersion: impact.policyengine_us_version ?? undefined, + dataset: impact.dataset_name ?? undefined, + datasetVersion: impact.dataset_version ?? undefined, + computedAt: impact.computed_at ?? undefined, + } : undefined, - title: record.title ?? record.id, - status: status || 'Analyzed', - summary: record.description ?? '', - provisions: provisionsFromReformParams(impact?.reform_params), - keyFindings: Array.isArray(record.key_findings) ? record.key_findings : undefined, - legiscanUrl: processedByKey.get(record.id)?.legiscan_url ?? undefined, - sourceUrl: record.url ?? undefined, - author: record.author ?? undefined, - date: record.date ?? undefined, - provenance: impact - ? { - modelVersion: impact.policyengine_us_version ?? undefined, - dataset: impact.dataset_name ?? undefined, - datasetVersion: impact.dataset_version ?? undefined, - computedAt: impact.computed_at ?? undefined, - } - : undefined, - validation: extractValidation(validationById.get(record.id), impact), - impacts: extractImpacts(impact), - impactData: extractImpactData(impact), - }; - }); + validation: extractValidation(validationById.get(record.id), impact), + impacts: extractImpacts(impact), + impactData: extractImpactData(impact), + }; + }); } /** diff --git a/app/src/components/flagship/ProvisionList.tsx b/app/src/components/flagship/ProvisionList.tsx index 72be9a755..38aa7fea9 100644 --- a/app/src/components/flagship/ProvisionList.tsx +++ b/app/src/components/flagship/ProvisionList.tsx @@ -19,9 +19,16 @@ export interface ProvisionListItem { * quiet context underneath, and states the change as current law → new * value. When the provision matches current law (enacted bills), the * arrow would be meaningless, so the row shows the single value with a - * "current law" note instead. + * "current law" note instead — or, for a bill scored against the law + * before it, the value alone (an empty note). */ -export default function ProvisionList({ provisions }: { provisions: ProvisionListItem[] }) { +export default function ProvisionList({ + provisions, + sameValueNote = 'current law', +}: { + provisions: ProvisionListItem[]; + sameValueNote?: string; +}) { const variables = useSelector((state: RootState) => state.metadata.variables); const variableLabel = (name: string) => variables?.[name]?.label ?? name; return ( @@ -109,7 +116,9 @@ export default function ProvisionList({ provisions }: { provisions: ProvisionLis > {proposed} {' '} - current law + {sameValueNote && ( + {sameValueNote} + )} ) : ( <> diff --git a/app/src/components/flagship/ProvisionSkeleton.tsx b/app/src/components/flagship/ProvisionSkeleton.tsx new file mode 100644 index 000000000..190771ab1 --- /dev/null +++ b/app/src/components/flagship/ProvisionSkeleton.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from '@/components/ui'; +import { colors, spacing } from '@/designTokens'; + +/** + * Placeholder rows for provisions whose names and units are still + * loading, in place of raw parameter paths and unformatted values + * ("gov.states.ga.tax.income.main.flat_rate: 0.0499"). + */ +export default function ProvisionSkeleton({ count }: { count: number }) { + const rows = Math.max(1, Math.min(count, 5)); + return ( +
+ {Array.from({ length: rows }, (_, index) => ( +
0 ? `1px solid ${colors.border.light}` : 'none', + }} + > +
+ + +
+ +
+ ))} +
+ ); +} diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 81aed92f5..bdfc29cc7 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -7,10 +7,14 @@ import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; +import { sameParameterValue } from '@/libs/draftReform'; +import { reportProvisionName } from '@/libs/flagship/draftLabels'; import { RunReportProvision } from '@/libs/flagship/runReport'; import { Reform } from '@/types/ingredients/Reform'; -import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { formatValue } from '@/utils/parameterValues'; +import ProvisionSkeleton from './ProvisionSkeleton'; +import NumberInput from './reform/ValueInput'; import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; @@ -19,6 +23,8 @@ interface ReportAdjustPanelProps { title: string; sourceNote: string; provisions: RunReportProvision[]; + /** The parameters' names and units are still loading: placeholders, not raw paths. */ + loading?: boolean; } /** True when the reform's parameter set is exactly the given provisions. */ @@ -46,6 +52,7 @@ export default function ReportAdjustPanel({ title, sourceNote, provisions, + loading = false, }: ReportAdjustPanelProps) { const runReport = useRunFlagshipReport(); const countryId = useCurrentCountry(); @@ -123,110 +130,129 @@ export default function ReportAdjustPanel({ meta={`${active.length} provision${active.length === 1 ? '' : 's'}`} defaultOpen={false} > - - {active.map((provision) => ( - - - - {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} - - - - + {/* The name in full, wrapping; the parameter's place in the tree on hover. */} + + {name} + + + + {/* The value to edit; what it was, quietly beside it, as Build shows it. */} +
+ {typeof values[provision.path] === 'boolean' ? ( + + setValues((current) => ({ ...current, [provision.path]: next })) + } + ariaLabel={`New value for ${name}`} + /> + ) : ( + // Build's box: rates as percentages, amounts with their currency. +
+ + setValues((current) => ({ ...current, [provision.path]: next })) + } + ariaLabel={`New value for ${name}`} + /> +
+ )} + {!sameParameterValue(values[provision.path], provision.baselineValue) && ( + + was {formatValue(provision.baselineValue, provision.unit)} + + )} +
+
+ ); + })} + {active.length === 0 && ( + - - {formatValue(provision.baselineValue, provision.unit)} → - - - setValues((current) => ({ ...current, [provision.path]: next })) - } - ariaLabel={`Adjusted value for ${provision.path}`} - /> -
- - ))} - {active.length === 0 && ( - - All provisions removed — restore some to recompute. - - )} - {removed.size > 0 && ( - - )} - + All provisions removed — restore some to recompute. + + )} + {removed.size > 0 && ( + + )} + + )} {(reconcileError || runReport.error) && ( diff --git a/app/src/components/flagship/ScopePicker.tsx b/app/src/components/flagship/ScopePicker.tsx index d03521c32..273e97d58 100644 --- a/app/src/components/flagship/ScopePicker.tsx +++ b/app/src/components/flagship/ScopePicker.tsx @@ -26,6 +26,10 @@ interface ScopePickerProps { states: ScopeOption[]; /** Trigger styling shared with the neighboring filter controls */ triggerStyle: React.CSSProperties; + /** How many items each option holds, keyed by option code, e.g. bills per state */ + counts?: Record; + /** The trigger's accessible name before its value, e.g. "State scope" */ + label?: string; } const JURISDICTIONS: ScopeOption[] = [ @@ -57,7 +61,14 @@ export function scopeFilter(value: string, search: string, keywords?: string[]): * The search scope as a filterable list: fifty-odd states are too many to * scan in a native select, so typing narrows them by name or code. */ -export default function ScopePicker({ value, onChange, states, triggerStyle }: ScopePickerProps) { +export default function ScopePicker({ + value, + onChange, + states, + triggerStyle, + counts, + label = 'State scope', +}: ScopePickerProps) { const [open, setOpen] = useState(false); const current = JURISDICTIONS.find((option) => option.code === value) ?? @@ -80,6 +91,18 @@ export default function ScopePicker({ value, onChange, states, triggerStyle }: S > {option.label} + {counts?.[option.code] !== undefined && ( + + {counts[option.code]} + + )} {showCode && ( + ); } diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 160fca147..d7f5f15c1 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -147,7 +147,6 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const adjusted = draft.provisions.filter((provision) => provisionAdjusts(provision, parameters?.[provision.path]?.values) ); - const leftOut = draft.provisions.length - adjusted.length; return (
@@ -232,7 +231,8 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { - {!empty && leftOut > 0 && ( + {/* Unchanged parameters still leave the run quietly; only "nothing changed" is said. */} + {!empty && adjusted.length === 0 && (

- {adjusted.length === 0 - ? 'Nothing is changed yet. Change a value to run the report.' - : `${leftOut} unchanged parameter${leftOut === 1 ? ' is' : 's are'} left out of the report.`} + Nothing is changed yet. Change a value to run the report.

)} diff --git a/app/src/components/flagship/reform/ValueInput.tsx b/app/src/components/flagship/reform/ValueInput.tsx index 0b3ef5376..a170388ea 100644 --- a/app/src/components/flagship/reform/ValueInput.tsx +++ b/app/src/components/flagship/reform/ValueInput.tsx @@ -11,6 +11,8 @@ interface ValueInputProps { param: ParameterMetadata; value: any; onChange: (value: any) => void; + /** The box's name, where no labelled wrapper names it. */ + ariaLabel?: string; } const CURRENCY_PREFIXES: Record = { @@ -38,14 +40,14 @@ function displayText(value: unknown, percentage: boolean): string { * or Enter) shows a check that the new value is kept. Yes/no values keep * the editor's switch. */ -export default function ValueInput({ param, value, onChange }: ValueInputProps) { +export default function ValueInput({ param, value, onChange, ariaLabel }: ValueInputProps) { if (param.unit === 'bool') { return ; } - return ; + return ; } -function NumberInput({ param, value, onChange }: ValueInputProps) { +function NumberInput({ param, value, onChange, ariaLabel }: ValueInputProps) { const percentage = param.unit === '/1'; const prefix = CURRENCY_PREFIXES[String(param.unit)] ?? ''; // While focused, the box shows what is typed, even an empty field; @@ -80,6 +82,7 @@ function NumberInput({ param, value, onChange }: ValueInputProps) { { startValue.current = value; diff --git a/app/src/components/flagship/report/DistrictImpactCard.tsx b/app/src/components/flagship/report/DistrictImpactCard.tsx index 7ae7f23f4..1459c95c1 100644 --- a/app/src/components/flagship/report/DistrictImpactCard.tsx +++ b/app/src/components/flagship/report/DistrictImpactCard.tsx @@ -15,7 +15,7 @@ import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSoc import { formatParameterValue } from '@/utils/chartValueUtils'; import { DIVERGING_GRAY_TEAL } from '@/utils/visualization/colorScales'; -type CongressionalMode = 'absolute' | 'relative' | 'winner-pct' | 'loser-pct'; +export type CongressionalMode = 'absolute' | 'relative' | 'winner-pct' | 'loser-pct'; const CONGRESSIONAL_MODE_OPTIONS = [ { label: 'Absolute', value: 'absolute' as CongressionalMode }, { label: 'Relative', value: 'relative' as CongressionalMode }, @@ -203,29 +203,56 @@ function DistrictListColumn({ * the navigable map with top and bottom rankings alongside, in normal * document flow. Must be rendered inside a CongressionalDistrictDataProvider. */ -export default function DistrictImpactCard({ output }: { output: SocietyWideReportOutput }) { +/** One district's results, in the shape the API's district breakdown uses. */ +export interface DistrictImpactRow { + district: string; + average_household_income_change: number; + /** Absent from the legislative tracker's stored districts. */ + relative_household_income_change?: number; + winner_percentage?: number; + loser_percentage?: number; +} + +export default function DistrictImpactCard({ + output, + districts, + modes, +}: { + /** The full results, whose district breakdown the card maps. */ + output?: SocietyWideReportOutput; + /** Districts given directly, e.g. a bill's stored estimates, in place of `output`'s. */ + districts?: DistrictImpactRow[]; + /** The views to offer, e.g. without "Relative" where the data lacks it. */ + modes?: CongressionalMode[]; +}) { const { labelLookup, stateCode } = useCongressionalDistrictData(); - const [congressionalMode, setCongressionalMode] = useState('absolute'); + const modeOptions = modes + ? CONGRESSIONAL_MODE_OPTIONS.filter((option) => modes.includes(option.value)) + : CONGRESSIONAL_MODE_OPTIONS; + const [congressionalMode, setCongressionalMode] = useState( + modeOptions[0]?.value ?? 'absolute' + ); const [mapVisualizationType, setMapVisualizationType] = useState('geographic'); const isOutcomeMode = congressionalMode === 'winner-pct' || congressionalMode === 'loser-pct'; const changeMetric = congressionalMode === 'relative' ? 'relative' : 'absolute'; const outcomeMetric = congressionalMode === 'winner-pct' ? 'winner' : 'loser'; - // Check if output already has district data (from nationwide calculation) - const savedDistricts = useMemo(() => { - if (!('congressional_district_impact' in output)) { - return null; - } - const districtData = (output as ReportOutputSocietyWideUS).congressional_district_impact; - if (!districtData?.districts) { + // Districts given directly, or the output's own breakdown (from the full calculation). + const savedDistricts = useMemo((): DistrictImpactRow[] | null => { + const source: DistrictImpactRow[] | undefined = districts + ? districts + : output && 'congressional_district_impact' in output + ? (output as ReportOutputSocietyWideUS).congressional_district_impact?.districts + : undefined; + if (!source) { return null; } - return districtData.districts.map((d) => ({ + return source.map((d) => ({ ...d, district: normalizeDistrictId(d.district), })); - }, [output]); + }, [districts, output]); const savedDistrictAvailability = useMemo( () => @@ -245,7 +272,7 @@ export default function DistrictImpactCard({ output }: { output: SocietyWideRepo value: changeMetric === 'absolute' ? item.average_household_income_change - : item.relative_household_income_change, + : (item.relative_household_income_change ?? 0), })); }, [changeMetric, labelLookup, savedDistricts]); @@ -324,7 +351,7 @@ export default function DistrictImpactCard({ output }: { output: SocietyWideRepo value: changeMetric === 'absolute' ? d.average_household_income_change - : d.relative_household_income_change, + : (d.relative_household_income_change ?? 0), }); } } @@ -416,7 +443,7 @@ export default function DistrictImpactCard({ output }: { output: SocietyWideRepo value={congressionalMode} onValueChange={(value) => setCongressionalMode(value as CongressionalMode)} size="xs" - options={CONGRESSIONAL_MODE_OPTIONS} + options={modeOptions} /> {visibleErrorCount > 0 && ( diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx new file mode 100644 index 000000000..4ffd6790d --- /dev/null +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -0,0 +1,378 @@ +import dayjs from 'dayjs'; +import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import type { BillChange } from '@/api/billFeed'; +import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; +import { CURRENT_YEAR, FOREVER } from '@/constants'; +import { colors, spacing, typography } from '@/designTokens'; +import { sameParameterValue } from '@/libs/draftReform'; +import { describeChange, reportProvisionName } from '@/libs/flagship/draftLabels'; +import type { RunReportProvision } from '@/libs/flagship/runReport'; +import { RootState } from '@/store'; +import { formatValue } from '@/utils/parameterValues'; + +/** Columns that show from the small breakpoint up; below it the values stack under the name. */ +const WIDE_ONLY = 'tw:hidden tw:sm:table-cell'; +/** The date columns need more room: they show from the medium breakpoint up. */ +const DATES_WIDE = 'tw:hidden tw:md:table-cell'; + +const headerCell: React.CSSProperties = { + padding: `0 ${spacing.md} ${spacing.sm}`, + textAlign: 'right', + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.text.secondary, + whiteSpace: 'nowrap', +}; + +const bodyCell: React.CSSProperties = { + padding: `${spacing.md} ${spacing.md}`, + verticalAlign: 'top', +}; + +/** Before and after: right-aligned like the numbers they mostly are, wrapping when long. */ +const valueCell: React.CSSProperties = { + textAlign: 'right', + minWidth: 88, + maxWidth: 220, + fontVariantNumeric: 'tabular-nums', +}; + +const dateCell: React.CSSProperties = { + textAlign: 'right', + whiteSpace: 'nowrap', + color: colors.text.secondary, + fontVariantNumeric: 'tabular-nums', +}; + +interface ChangeRow { + key: string; + name: string; + /** What the change does, in words: the tracker's explanation or the parameter's description. */ + detail?: string; + /** Where in the bill, e.g. "Section 1, amending C.G.S. § 12-700". */ + section?: string; + before?: string; + after: string; + /** The value is today's law already, so there is no "before" to show. */ + currentLaw?: boolean; + /** When the change applies (ISO dates); absent when it cannot be told. */ + dates?: ChangeDates; +} + +interface ChangeDates { + start: string; + end: string; +} + +/** + * When a provision's change applies: its first dated value's start to + * its last one's end — or, undated, from this year on, as it runs. + */ +function provisionDates(provision: Pick): ChangeDates { + const intervals = provision.intervals ?? []; + if (intervals.length === 0) { + return { start: `${CURRENT_YEAR}-01-01`, end: FOREVER }; + } + return { + start: intervals.reduce( + (min, i) => (i.startDate < min ? i.startDate : min), + intervals[0].startDate + ), + end: intervals.reduce((max, i) => (i.endDate > max ? i.endDate : max), intervals[0].endDate), + }; +} + +/** 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)); + +/** + * What the reform changes, as a table: a row each with the change's + * name and what it does, then its value before and after. A tracked + * bill's rows are the changes as the tracker scored them; otherwise + * each provision reads against today's law, with the parameter's own + * description as its detail. + * + * An enacted bill without the tracker's rows is the exception: its + * results compare with the law before it, but today's values already + * include it, so there is no "before" to show. Its values read alone, + * under a note that says what they are compared with. + */ +export default function ReportChanges({ + provisions, + changes, + priorLawBaseline = false, + loading = false, +}: { + provisions: RunReportProvision[]; + changes?: BillChange[]; + /** The parameters' names and units are still loading: placeholders, not raw paths. */ + loading?: boolean; + /** The results compare with the law before the reform, which today's values include. */ + priorLawBaseline?: boolean; +}) { + const parameters = useSelector((state: RootState) => state.metadata.parameters); + const variables = useSelector((state: RootState) => state.metadata.variables); + const variableLabel = (name: string) => variables?.[name]?.label ?? name; + + const scored = Boolean(changes && changes.length > 0); + // A tracker row has no dates of its own: it takes those of the parameter + // it names, or the bill's when every provision shares one range. + const allDates = provisions.map(provisionDates); + const sharedDates = + allDates.length > 0 && + allDates.every((d) => d.start === allDates[0].start && d.end === allDates[0].end) + ? allDates[0] + : undefined; + const changeDates = (change: BillChange): ChangeDates | undefined => { + const named = change.parameter + ? provisions.filter((provision) => namesPath(change.parameter!, provision.path)) + : []; + if (named.length > 0) { + const dates = named.map(provisionDates); + return { + start: dates.reduce((min, d) => (d.start < min ? d.start : min), dates[0].start), + end: dates.reduce((max, d) => (d.end > max ? d.end : max), dates[0].end), + }; + } + return sharedDates; + }; + const rows: ChangeRow[] = + changes && changes.length > 0 + ? changes.map((change, index) => ({ + key: `${change.label}-${index}`, + name: change.label, + detail: change.explanation, + section: change.section, + before: change.before, + after: change.after, + dates: changeDates(change), + })) + : provisions.map((provision) => { + const format = (value: unknown) => formatValue(value, provision.unit); + const currentLaw = sameParameterValue(provision.baselineValue, provision.value); + const list = Array.isArray(provision.value) && !currentLaw; + return { + key: provision.path, + name: reportProvisionName(provision), + detail: parameters?.[provision.path]?.description ?? undefined, + // A set of variables reads as what it takes out and puts in. + before: currentLaw || list ? undefined : format(provision.baselineValue), + after: list + ? describeChange(provision.baselineValue, provision.value, String, variableLabel) + : format(provision.value), + currentLaw, + dates: provisionDates(provision), + }; + }); + + if (rows.length === 0) { + return null; + } + + return ( +
+

+ + Policy changes +

+ {!(loading && !scored) && + priorLawBaseline && + !scored && + rows.some((row) => row.before === undefined) && ( +

+ The values this bill sets. The estimates compare them with the law before the bill. +

+ )} + {loading && !scored ? ( + // The tracker's write-up needs no metadata; rows read from parameters do. + + ) : ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
+ Change + + Before + + After + + Start + + End +
+ + {row.name} + + {row.detail && ( + + {row.detail} + + )} + {row.section && ( + + {row.section} + + )} + {/* Narrow screens: the values under the name, not in columns. */} + + {row.before ?? '—'} →{' '} + + {row.after} + + + {/* Below the medium breakpoint the dates fold in too. */} + {row.dates && ( + + {row.dates.end === FOREVER + ? `From ${dateText(row.dates.start)}` + : `${dateText(row.dates.start)} – ${dateText(row.dates.end)}`} + + )} + + {row.before ?? '—'} + + {row.after} + {/* For a bill scored against the law before it, today's law is no "before". */} + {row.currentLaw && !priorLawBaseline && ( + + current law + + )} + + {row.dates ? dateText(row.dates.start) : '—'} + + {row.dates ? endText(row.dates.end) : '—'} +
+
+ )} +
+ ); +} diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index 794cd496e..c63d4c5ef 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -1,14 +1,16 @@ import { useState, type ReactNode } from 'react'; import { IconHome, IconPlus } from '@tabler/icons-react'; -import { useSelector } from 'react-redux'; +import type { BillChange } from '@/api/billFeed'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { CalibrationMatchSection, type CalibrationPin, } from '@/components/flagship/CalibrationMatches'; import ProvisionList from '@/components/flagship/ProvisionList'; +import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; import DistrictImpactCard from '@/components/flagship/report/DistrictImpactCard'; import EconomicImpactCharts from '@/components/flagship/report/EconomicImpactCharts'; +import ReportChanges from '@/components/flagship/report/ReportChanges'; import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; import ReportContents, { reportMetrics, @@ -35,12 +37,9 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import type { CalibrationMatches } from '@/libs/flagship/calibrationMatching'; -import { describeChange } from '@/libs/flagship/draftLabels'; import type { RunReportProvision } from '@/libs/flagship/runReport'; import { ConstituencySubPage } from '@/pages/report-output/ConstituencySubPage'; import { canShowCongressionalDistrictImpactCard } from '@/pages/report-output/MigrationSubPage'; -import { RootState } from '@/store'; -import { formatValue } from '@/utils/parameterValues'; const SECTIONS = [ { id: 'policy', label: 'Overview' }, @@ -74,6 +73,22 @@ export interface ReportViewProps { /** e.g. "Utah · Introduced" for bills, "Hand-built draft" for drafts. */ sourceNote: string; provisions: RunReportProvision[]; + /** + * The overview's list of changes, before and after, when the source + * wrote them up — a tracked bill's as scored. Without them, the list + * reads the provisions against today's law. + */ + changes?: BillChange[]; + /** + * The results compare with the law before the reform, which today's + * values already include — an enacted bill scored before it passed. + */ + priorLawBaseline?: boolean; + /** + * The parameters' names and units are still loading. Lists of + * provisions show placeholders meanwhile, not raw parameter paths. + */ + provisionsLoading?: boolean; customBaselineLabel?: string | null; year: string; region?: string; @@ -88,6 +103,8 @@ export interface ReportViewProps { pending?: ReactNode; /** Shown in economic impacts instead of `pending`, e.g. with stored estimates. */ economyPending?: ReactNode; + /** Shown in districts instead of `pending`, e.g. with stored district estimates. */ + districtsPending?: ReactNode; /** Content above the overview's section cards, e.g. a bill's summary. */ overviewLead?: ReactNode; /** Content below the overview's section cards, e.g. data provenance. */ @@ -109,6 +126,9 @@ export default function ReportView({ heading = title, sourceNote, provisions, + changes, + priorLawBaseline = false, + provisionsLoading = false, customBaselineLabel, year, region, @@ -119,6 +139,7 @@ export default function ReportView({ storedMetricsNote, pending, economyPending = pending, + districtsPending = pending, overviewLead, overviewFooter, calibration, @@ -126,8 +147,6 @@ export default function ReportView({ trackRecord, validationLead, }: ReportViewProps) { - const variables = useSelector((state: RootState) => state.metadata.variables); - const variableLabel = (name: string) => variables?.[name]?.label ?? name; const nav = useAppNavigate(); const countryId = useCurrentCountry(); const [activeTab, setActiveTab] = useState('policy'); @@ -145,6 +164,11 @@ export default function ReportView({ }); } + // Whether the report's scope has districts at all, results loaded or not: + // nationwide or one state. showUSDistricts also needs the run's policies. + const districtScope = + countryId === 'us' && + (!region || region.toLowerCase() === 'us' || region.toLowerCase().startsWith('state/')); const showUSDistricts = canShowCongressionalDistrictImpactCard({ countryId, reformPolicyId, @@ -208,30 +232,6 @@ export default function ReportView({ Compared with: {customBaselineLabel} )} - {provisions.map((provision) => ( - - {provision.path.startsWith('gov.irs.credits.ctc.amount.base') - ? 'Maximum credit per child' - : provision.breadcrumb || provision.path} - :{' '} - - {describeChange( - provision.baselineValue, - provision.value, - (value) => formatValue(value, provision.unit), - variableLabel - )} - - - ))} {sourceNote && ( {overviewLead} + {overviewFooter} @@ -296,7 +302,7 @@ export default function ReportView({ 'districts', - {!output && pending} + {!output && districtsPending} {output && showUSDistricts && ( Your reform · {year} - + {provisionsLoading ? ( + + ) : ( + + )} ) : ( Before-and-after details for your reform are unavailable. @@ -389,7 +402,12 @@ export default function ReportView({ - + ); diff --git a/app/src/components/flagship/report/StoredImpactCharts.tsx b/app/src/components/flagship/report/StoredImpactCharts.tsx index 633f14989..341c33977 100644 --- a/app/src/components/flagship/report/StoredImpactCharts.tsx +++ b/app/src/components/flagship/report/StoredImpactCharts.tsx @@ -73,6 +73,26 @@ function Legend({ items }: { items: ReadonlyArray<{ label: string; color: string ); } +type PovertyRates = NonNullable['poverty']>; + +/** A poverty rate's relative change in percent: stored, or from the rates. */ +function povertyPercentChange(rates: PovertyRates | undefined): number | null { + if (typeof rates?.percentChange === 'number') { + return rates.percentChange; + } + if ( + typeof rates?.baselineRate === 'number' && + typeof rates?.reformRate === 'number' && + rates.baselineRate > 0 + ) { + return ((rates.reformRate - rates.baselineRate) / rates.baselineRate) * 100; + } + return null; +} + +const signedPercent = (value: number) => + `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`; + /** * The legislative tracker's stored distributional and poverty results for * a bill, in the same chart cards as a report's economic impacts. Shown @@ -95,14 +115,14 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im ] : []; + // The change only, not the rates before and after. const povertyRows = [ { group: 'All people', rates: impact?.poverty }, { group: 'Children', rates: impact?.childPoverty }, - ].flatMap(({ group, rates }) => - typeof rates?.baselineRate === 'number' && typeof rates?.reformRate === 'number' - ? [{ group, baseline: rates.baselineRate * 100, reform: rates.reformRate * 100 }] - : [] - ); + ].flatMap(({ group, rates }) => { + const change = povertyPercentChange(rates); + return change === null ? [] : [{ group, change }]; + }); if (average.length + relative.length + winnersRows.length + povertyRows.length === 0) { return null; @@ -227,23 +247,9 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im )} {povertyRows.length > 0 && ( - - } - > + - + `${value.toFixed(0)}%`} + tickFormatter={(value: number) => `${value.toFixed(1)}%`} tickLine={false} axisLine={false} tick={AXIS_TICK} - width={44} + width={52} /> [ - `${Number(value ?? 0).toFixed(1)}%`, - name === 'baseline' ? 'Current law' : 'With this bill', - ]} + formatter={(value) => [signedPercent(Number(value ?? 0)), 'Relative change']} cursor={{ fill: colors.gray[50] }} /> - + `${Number(value ?? 0).toFixed(1)}%`} + formatter={(value) => signedPercent(Number(value ?? 0))} style={{ fontSize: 12, fill: colors.text.secondary }} /> - - - `${Number(value ?? 0).toFixed(1)}%`} - style={{ fontSize: 12, fill: colors.text.primary }} - /> + {povertyRows.map((row) => ( + + ))} diff --git a/app/src/hooks/useBillEconomy.ts b/app/src/hooks/useBillEconomy.ts index f5dc3f1ea..472699d5d 100644 --- a/app/src/hooks/useBillEconomy.ts +++ b/app/src/hooks/useBillEconomy.ts @@ -1,9 +1,8 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import type { TrackedBill } from '@/api/billFeed'; +import { billRunYear, type TrackedBill } from '@/api/billFeed'; import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { CURRENT_YEAR } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { createReformPolicy } from '@/libs/flagship/runReport'; @@ -53,6 +52,8 @@ export function useBillEconomy( const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); const region = bill ? billRegion(bill, countryId) : countryId; const provisions = bill?.provisions ?? []; + // A bill from a later year is scored in its first year: earlier, it changes nothing. + const year = String(billRunYear(provisions)); const policy = useQuery({ queryKey: ['flagship-bill-policy', countryId, bill?.id, provisions], @@ -75,7 +76,7 @@ export function useBillEconomy( ); const economy = useQuery({ - queryKey: ['flagship-bill-economy', countryId, reformPolicyId, baselinePolicyId, region], + queryKey: ['flagship-bill-economy', countryId, reformPolicyId, baselinePolicyId, region, year], queryFn: () => strategy.execute( { @@ -84,7 +85,7 @@ export function useBillEconomy( policyIds: { baseline: baselinePolicyId!, reform: reformPolicyId }, populationId: region, region, - year: CURRENT_YEAR, + year, calcId: metadata.calcId, }, metadata @@ -115,7 +116,7 @@ export function useBillEconomy( queuePosition: economy.data?.queuePosition, reformPolicyId, baselinePolicyId, - year: CURRENT_YEAR, + year, region, retry: () => { if (policy.isError) { diff --git a/app/src/hooks/useStoredBillDistricts.ts b/app/src/hooks/useStoredBillDistricts.ts new file mode 100644 index 000000000..6ed410d69 --- /dev/null +++ b/app/src/hooks/useStoredBillDistricts.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchTrackerBillDistricts } from '@/api/billFeed'; + +/** + * A tracked bill's stored congressional-district results, for its + * Districts tab while the full results are not loaded. Null when the + * tracker has none for the bill. + */ +export function useStoredBillDistricts(billId: string | undefined) { + return useQuery({ + queryKey: ['tracker-bill-districts', billId], + queryFn: () => fetchTrackerBillDistricts(billId!), + enabled: !!billId, + staleTime: Infinity, + }); +} diff --git a/app/src/libs/flagship/billMetrics.ts b/app/src/libs/flagship/billMetrics.ts index 7d5fd4b56..00dd8f06e 100644 --- a/app/src/libs/flagship/billMetrics.ts +++ b/app/src/libs/flagship/billMetrics.ts @@ -71,14 +71,16 @@ function sameValue(value: unknown, baselineValue: unknown): boolean { */ export function lawValuesFrom( values: Record | undefined | null, - fromDate: string + fromDate: string, + /** The last day to read, for a value the bill sets for a while only. */ + toDate?: string ): unknown[] { if (!values) { return []; } const dates = Object.keys(values).sort(); const inEffect = dates.filter((date) => date <= fromDate).pop(); - const later = dates.filter((date) => date > fromDate); + const later = dates.filter((date) => date > fromDate && (!toDate || date <= toDate)); return [...(inEffect ? [inEffect] : []), ...later].map((date) => values[date]); } diff --git a/app/src/libs/flagship/billProvisions.ts b/app/src/libs/flagship/billProvisions.ts new file mode 100644 index 000000000..6ad2d081d --- /dev/null +++ b/app/src/libs/flagship/billProvisions.ts @@ -0,0 +1,91 @@ +import { intervalValueAt, 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'; +import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; + +/** The value current law gives a parameter on a date. */ +function lawValueOn(values: Record | undefined | null, date: string): unknown { + return lawValuesFrom(values, date, date)[0]; +} + +/** The value the tracker compared a provision with in the run year, when it stored it. */ +function scoredBaseline(provision: TrackedBillProvision, date: string): unknown { + const intervals = provision.baselineIntervals; + if (!intervals || intervals.length === 0) { + return undefined; + } + const inRunYear = intervalValueAt(intervals, date); + return inRunYear === undefined ? intervals[0].value : inRunYear; +} + +/** + * A tracked bill's provisions as report provisions: each keeps every + * dated value the bill sets, and reads against the law in effect when + * the bill's run year starts — not today's, which for a bill from 2027 + * can differ (an indexed amount, a scheduled rate cut). + * + * A bill that is already law (`priorLaw`) reads against the law the + * tracker compared it with instead: today's values include the bill, so + * they say nothing about what it changed. The tracker's values also + * stand in where the model's metadata lacks a parameter. + */ +export function billReportProvisions( + provisions: TrackedBillProvision[], + parameters: ParameterMetadataCollection | null | undefined, + year: number, + { priorLaw = false }: { priorLaw?: boolean } = {} +): RunReportProvision[] { + const runStart = `${year}-01-01`; + return provisions.map((provision) => { + const metadata = parameters?.[provision.path]; + const scored = scoredBaseline(provision, runStart); + const law = lawValueOn(metadata?.values, runStart); + return { + path: provision.path, + breadcrumb: + metadata && parameters + ? formatLabelParts(getHierarchicalLabels(provision.path, parameters)) + : (provision.fallbackBreadcrumb ?? provision.path), + unit: metadata?.unit ?? null, + baselineValue: (priorLaw && scored !== undefined) || law === undefined ? scored : law, + value: provision.value, + ...(provision.intervals ? { intervals: provision.intervals } : {}), + }; + }); +} + +/** + * Whether today's law already has every value the bill sets, from its + * run year on — an enacted bill. Each dated value is checked against the + * law over its own dates, so a one-year change that matches that year's + * law counts, and a bill that freezes a value current law schedules to + * change does not. + */ +export function billAlreadyCurrentLaw( + provisions: TrackedBillProvision[], + parameters: ParameterMetadataCollection | null | undefined, + year: number +): boolean { + const runStart = `${year}-01-01`; + return isAlreadyCurrentLaw( + provisions.flatMap((provision) => { + const values = parameters?.[provision.path]?.values; + if (!provision.intervals) { + return [{ value: provision.value, lawValues: lawValuesFrom(values, runStart) }]; + } + return ( + provision.intervals + // Dates before the run year are not scored. + .filter((interval) => interval.endDate >= runStart) + .map((interval) => { + const from = interval.startDate > runStart ? interval.startDate : runStart; + return { + value: interval.value, + lawValues: lawValuesFrom(values, from, interval.endDate), + }; + }) + ); + }) + ); +} diff --git a/app/src/libs/flagship/draftLabels.ts b/app/src/libs/flagship/draftLabels.ts index bde31c59a..44e9b851f 100644 --- a/app/src/libs/flagship/draftLabels.ts +++ b/app/src/libs/flagship/draftLabels.ts @@ -23,6 +23,19 @@ export function provisionName(provision: Pick +): string { + return provision.path.startsWith('gov.irs.credits.ctc.amount.base') + ? 'Maximum credit per child' + : provisionName(provision); +} + /** * A change as a reader says it: "$1,700 → $2,500", or for a set of * variables, what the reform takes out and puts in. diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index da5b9e7f3..527347729 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -1,8 +1,12 @@ +import { useState } from 'react'; import { IconCalendar, IconExternalLink, IconUser } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import type { TrackedBill } from '@/api/billFeed'; +import { billRunYear, type TrackedBill } from '@/api/billFeed'; import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; +import DistrictImpactCard, { + type CongressionalMode, +} from '@/components/flagship/report/DistrictImpactCard'; import ReportView from '@/components/flagship/report/ReportView'; import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts'; import { @@ -11,15 +15,15 @@ import { ValidationChip, } from '@/components/flagship/ValidationPanel'; import { Button, Spinner, Stack, Text } from '@/components/ui'; -import { CURRENT_YEAR } from '@/constants'; +import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistrictDataContext'; import { colors, spacing, typography } from '@/designTokens'; import { useBillEconomy, type BillEconomy } from '@/hooks/useBillEconomy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; -import { isAlreadyCurrentLaw, lawValuesFrom, storedBillMetrics } from '@/libs/flagship/billMetrics'; +import { storedBillMetrics } from '@/libs/flagship/billMetrics'; +import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; import { RootState } from '@/store'; -import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; -import { getCurrentValue } from '@/utils/parameterValues'; interface BillReportPageProps { /** Passed by the Next.js route bridge; react-router falls back to params. */ @@ -99,7 +103,12 @@ function BillOverviewLead({ bill }: { bill: TrackedBill }) { } /** Why a bill has, or does not yet have, full results. */ -type RunState = 'already-law' | 'no-provisions' | 'no-metadata' | BillEconomy['status']; +type RunState = + | 'already-law' + | 'no-provisions' + | 'no-metadata' + | 'not-started' + | BillEconomy['status']; const UNAVAILABLE_COPY: Partial> = { 'already-law': { @@ -117,6 +126,9 @@ const UNAVAILABLE_COPY: Partial void; +}) { const unavailable = UNAVAILABLE_COPY[runState]; if (unavailable) { return ( @@ -137,6 +157,24 @@ function FullResultsStatus({ runState, economy }: { runState: RunState; economy: ); } + if (runState === 'not-started') { + return ( + + + The full results are not loaded. + + {/* Whether they exist is unknown until asked: another reader may have run the bill. */} + + This page shows the legislative tracker's stored estimates. The full results score + the bill with today's model. If no one has run this bill yet, that takes several + minutes. + + + + ); + } if (runState === 'error') { return ( @@ -170,14 +208,16 @@ const STORED_NOTE: Partial> = { 'already-law': 'Estimates from the legislative tracker, against the law before this bill', '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', error: 'Stored estimates from the legislative tracker · full results unavailable', pending: 'Stored estimates from the legislative tracker · full results calculating', }; /** * A tracked bill as a flagship report: the same layout as a saved report, - * opening on the tracker's stored headline numbers while the full - * society-wide results calculate, then filling in every section. + * opening on the tracker's stored headline numbers. The full society-wide + * results calculate only when the reader asks — opening the page, or one + * of its tabs, starts no run — and then fill in every section. */ export default function BillReportPage({ billId: propId }: BillReportPageProps) { const params = useParams<{ billId: string }>(); @@ -187,42 +227,41 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) const metadataLoaded = useSelector((state: RootState) => state.metadata.version !== null); const metadataError = useSelector((state: RootState) => state.metadata.error); const { bills, isLoading } = useTrackedBills(countryId); + // The bill whose full run the reader started; another bill starts unrun. + const [runRequestedFor, setRunRequestedFor] = useState(null); + const runRequested = runRequestedFor === billId; const bill: TrackedBill | undefined = bills.find((candidate) => candidate.id === billId); + const { data: storedDistricts } = useStoredBillDistricts(bill?.id); - const provisions = (bill?.provisions ?? []).map((provision) => { - const metadata = parameters?.[provision.path]; - return { - path: provision.path, - breadcrumb: metadata - ? formatLabelParts(getHierarchicalLabels(provision.path, parameters)) - : (provision.fallbackBreadcrumb ?? provision.path), - unit: metadata?.unit ?? null, - baselineValue: getCurrentValue(metadata?.values), - value: provision.value, - }; - }); + // A bill from a later year runs, and reads against the law, in its first year. + const runYear = billRunYear(bill?.provisions ?? []); // An enacted bill already matches current law, so a run against current - // law would score it as no change; keep the tracker's prior-law estimate. - const alreadyCurrentLaw = isAlreadyCurrentLaw( - (bill?.provisions ?? []).map((provision) => ({ - value: provision.value, - lawValues: lawValuesFrom(parameters?.[provision.path]?.values, `${CURRENT_YEAR}-01-01`), - })) - ); + // law would score it as no change; keep the tracker's prior-law estimate, + // and read its changes against the law the tracker compared it with. + const alreadyCurrentLaw = billAlreadyCurrentLaw(bill?.provisions ?? [], parameters, runYear); + const provisions = billReportProvisions(bill?.provisions ?? [], parameters, runYear, { + priorLaw: alreadyCurrentLaw, + }); const billPaths = provisions.map((p) => p.path); const trackRecord = useModelTrackRecord(billPaths); - // Wait for metadata so an enacted bill is recognized before any run starts. - const economy = useBillEconomy(bill, { enabled: metadataLoaded && !alreadyCurrentLaw }); + // 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, + }); const runState: RunState = alreadyCurrentLaw ? 'already-law' : provisions.length === 0 ? 'no-provisions' : metadataError && !metadataLoaded ? 'no-metadata' - : economy.status; + : !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( billPaths, @@ -254,8 +293,14 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) return ( } + pending={} economyPending={ - + } + districtsPending={ + + + {storedDistricts && storedDistricts.length > 0 && ( + // The tracker's districts, until the full results bring their own. + + + + )} + + } overviewLead={} calibration={calibration} trackRecord={trackRecord} diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 7a8a0d793..c09cd9cf4 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -13,9 +13,10 @@ import { } from '@tabler/icons-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { TrackedBill } from '@/api/billFeed'; +import { billRunYear, TrackedBill } from '@/api/billFeed'; import { getReformStore } from '@/api/reformStore'; import ProvisionList from '@/components/flagship/ProvisionList'; +import ScopePicker from '@/components/flagship/ScopePicker'; import StatTile from '@/components/flagship/StatTile'; import ValueInput from '@/components/flagship/ValueInput'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; @@ -33,6 +34,7 @@ import { loadReformIntoDraft, setDraftLabel, } from '@/libs/draftReform'; +import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; import { RootState } from '@/store'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; import { formatBudgetaryImpact } from '@/utils/formatPowers'; @@ -55,6 +57,21 @@ type Tab = 'bills' | 'yours'; /** Bills render in pages of this size; scrolling near the end reveals the next page. */ const BILLS_PAGE_SIZE = 12; +/** The list's filter controls — place and search — at one height and border. */ +const filterControl: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: spacing.xs, + height: 34, + padding: `0 ${spacing.md}`, + border: `1px solid ${colors.border.light}`, + borderRadius: spacing.radius.container, + background: colors.background.primary, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + color: colors.text.secondary, +}; + interface ProvisionView { path: string; breadcrumb: string; @@ -298,17 +315,14 @@ export default function ReformsPage() { ? formatLabelParts(getHierarchicalLabels(path, parameters)) : (fallback ?? path); - const billProvisions = (bill: TrackedBill): ProvisionView[] => - bill.provisions.map((provision) => { - const metadata = parameters?.[provision.path]; - return { - path: provision.path, - breadcrumb: resolveBreadcrumb(provision.path, provision.fallbackBreadcrumb), - unit: metadata?.unit ?? null, - baselineValue: getCurrentValue(metadata?.values), - value: provision.value, - }; + // Each with its dated values, read against the law in the bill's run year + // — or, for a bill that is law already, the law the tracker compared it with. + const billProvisions = (bill: TrackedBill) => { + const year = billRunYear(bill.provisions); + return billReportProvisions(bill.provisions, parameters, year, { + priorLaw: billAlreadyCurrentLaw(bill.provisions, parameters, year), }); + }; const reformProvisions = (reform: Reform): ProvisionView[] => reform.parameters.map((parameter) => { @@ -322,10 +336,30 @@ export default function ReformsPage() { }; }); - const places = useMemo( - () => [...new Set(bills.map((bill) => bill.jurisdiction))].sort((a, b) => a.localeCompare(b)), - [bills] - ); + // Places with bills, for the place picker: the states by name, and how + // many bills each scope holds. Federal bills carry no state. + const { placeStates, placeCounts } = useMemo(() => { + const states = new Map(); + const counts: Record = { all: bills.length, federal: 0 }; + for (const bill of bills) { + const code = bill.state?.toLowerCase(); + if (code) { + states.set(code, bill.jurisdiction); + counts[code] = (counts[code] ?? 0) + 1; + } else { + counts.federal += 1; + } + } + return { + placeStates: [...states] + .map(([code, label]) => ({ code, label })) + .sort((a, b) => a.label.localeCompare(b.label)), + placeCounts: counts, + }; + }, [bills]); + const inPlace = (bill: TrackedBill) => + placeFilter === 'all' || + (placeFilter === 'federal' ? !bill.state : bill.state?.toLowerCase() === placeFilter); const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const matches = (haystack: string) => @@ -333,9 +367,7 @@ export default function ReformsPage() { const visibleBills = bills .filter( - (bill) => - (placeFilter === 'all' || bill.jurisdiction === placeFilter) && - matches(`${bill.title} ${bill.jurisdiction} ${bill.summary}`) + (bill) => inPlace(bill) && matches(`${bill.title} ${bill.jurisdiction} ${bill.summary}`) ) .sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')); @@ -452,10 +484,6 @@ export default function ReformsPage() { const betterOff = winners && ((winners.gainMore5Pct ?? 0) + (winners.gainLess5Pct ?? 0)) * 100; const worseOff = winners && ((winners.loseMore5Pct ?? 0) + (winners.loseLess5Pct ?? 0)) * 100; const poverty = impact?.poverty; - const povertyDetail = - typeof poverty?.baselineRate === 'number' && typeof poverty?.reformRate === 'number' - ? `${(poverty.baselineRate * 100).toFixed(1)}% → ${(poverty.reformRate * 100).toFixed(1)}%` - : undefined; const revenue = selectedBill.impacts?.revenue ?? impact?.budgetary?.stateRevenueImpact; const money = (value: number) => { const { display, label } = formatBudgetaryImpact(value); @@ -470,14 +498,9 @@ export default function ReformsPage() { {backLink} - - - {selectedBill.jurisdiction} · {selectedBill.status} - - - {selectedBill.title} - - + + {selectedBill.title} + {selectedBill.summary && ( 0 ? '+' : '−'}${Math.abs(poverty.percentChange).toFixed(1)}%`} label="poverty rate change" - detail={povertyDetail} /> )} {typeof betterOff === 'number' && betterOff > 0 && ( @@ -510,7 +532,9 @@ export default function ReformsPage() { )} {provisions.length > 0 ? ( - + // A bill's value in today's law has passed, and was scored against the law + // before it, so "current law" would mislead; the value reads alone. + ) : ( Parameter mapping for this bill hasn't been published yet — report and draft actions @@ -533,10 +557,13 @@ export default function ReformsPage() { ) : (