From 820003271382ae108fcf3fe0a711bdb236938711 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Sun, 12 Apr 2026 15:59:31 -0400 Subject: [PATCH 1/7] Prototype unified state and federal bill tracker --- README.md | 6 +- docs/GENERAL_BILL_TRACKER_ARCHITECTURE.md | 133 ++++++++ src/App.jsx | 150 ++++++--- src/components/BillActivityFeed.jsx | 15 +- src/components/Breadcrumb.jsx | 15 +- src/components/FederalPanel.jsx | 376 ++++++++++++++++++++++ src/components/SessionFilterBar.jsx | 123 +++++++ src/components/StatePanel.jsx | 105 ++++-- src/components/StateSearchCombobox.jsx | 69 ++-- src/context/DataContext.jsx | 105 ++++-- src/lib/jurisdictions.js | 16 + src/lib/sessionFilters.js | 52 +++ 12 files changed, 1018 insertions(+), 147 deletions(-) create mode 100644 docs/GENERAL_BILL_TRACKER_ARCHITECTURE.md create mode 100644 src/components/FederalPanel.jsx create mode 100644 src/components/SessionFilterBar.jsx create mode 100644 src/lib/jurisdictions.js create mode 100644 src/lib/sessionFilters.js diff --git a/README.md b/README.md index 0a1a7eb..55c9290 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# State Legislative Tracker +# Tax and Transfer Bill Tracker -Tracks state tax and benefit legislation relevant to [PolicyEngine](https://policyengine.org), scores bills for modelability, and computes fiscal impacts using microsimulation. +Tracks state and federal tax and transfer legislation relevant to [PolicyEngine](https://policyengine.org), while keeping a state-first browsing experience for state bills. The pipeline scores bills for modelability and computes fiscal impacts using microsimulation. **Live app:** [state-legislative-tracker.modal.run](https://policengine--state-legislative-tracker.modal.run) @@ -27,7 +27,7 @@ Tracks state tax and benefit legislation relevant to [PolicyEngine](https://poli ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ React Frontend (Modal) │ -│ Dashboard showing scored bills, impact analyses, district maps │ +│ State-first tracker with a federal workspace and shared analysis │ └─────────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/GENERAL_BILL_TRACKER_ARCHITECTURE.md b/docs/GENERAL_BILL_TRACKER_ARCHITECTURE.md new file mode 100644 index 0000000..27c804b --- /dev/null +++ b/docs/GENERAL_BILL_TRACKER_ARCHITECTURE.md @@ -0,0 +1,133 @@ +# Unified Bill Tracker Direction + +## Goal + +Move from a `2026 state legislative session tracker` to a broader `tax and transfer bill tracker` that: + +- keeps `state` browsing as the main user experience for state legislation +- adds `federal` as a first-class destination instead of a special case +- supports multiple sessions instead of centering the product on one year +- keeps the existing scoring, encoding, and microsimulation workflow + +## What Is Coupled Today + +### Product framing + +- [README.md](/Users/pavelmakarchuk/state-research-tracker/README.md) originally framed the app as a state legislative tracker +- [src/App.jsx](/Users/pavelmakarchuk/state-research-tracker/src/App.jsx) was hard-coded around `2026 State Legislative Tracker` and state-session language + +### Routing + +- [src/App.jsx](/Users/pavelmakarchuk/state-research-tracker/src/App.jsx) originally only understood: + - `/` + - `/:state` + - `/:state/:billId` +- that makes `state` the only valid top-level destination + +### Static state/session backbone + +- [src/data/states.js](/Users/pavelmakarchuk/state-research-tracker/src/data/states.js) still carries important display metadata +- the problem is not that it exists; the problem is when it doubles as the application structure + +### Content model + +- [src/components/StatePanel.jsx](/Users/pavelmakarchuk/state-research-tracker/src/components/StatePanel.jsx) is correctly state-first, but federal content only appears as an attachment to states +- [src/context/DataContext.jsx](/Users/pavelmakarchuk/state-research-tracker/src/context/DataContext.jsx) still treats federal research as a special-case fake-state model + +### Pipeline assumptions + +- [scripts/openstates_monitor.py](/Users/pavelmakarchuk/state-research-tracker/scripts/openstates_monitor.py) and [scripts/refresh_bill_status.py](/Users/pavelmakarchuk/state-research-tracker/scripts/refresh_bill_status.py) are state/OpenStates-specific +- federal ingestion will need a second source, but it should plug into the same downstream bill pipeline + +## Product Direction + +The right structure is: + +- state-first UX +- federal as a peer surface +- jurisdiction-first data model underneath + +That means: + +- the homepage still starts with states +- the map remains useful for state legislation +- federal gets its own page and navigation affordance +- sessions remain visible and useful, but they stop being the product backbone + +## Recommended UI Shape + +### Keep these + +- homepage map and state search +- state pages as the primary state workflow +- state bill detail pages + +### Add these + +- `/federal` as a first-class route +- a federal page using the same research and bill pipeline concepts +- search and breadcrumbs that understand both state and federal destinations + +### Add later if it proves useful + +- shared bill detail routes independent of state/federal +- session views such as `2026 session` or `119th Congress` +- a generic bill index across jurisdictions + +## Data Model Direction + +The schema should move toward explicit jurisdiction fields. + +For `processed_bills` and `research`, prefer: + +- `jurisdiction_type` +- `jurisdiction_code` +- `jurisdiction_name` +- `session_name` + +Keep `session` and `year` separate: + +- `session_name` is the primary legislative unit +- `activity_year` is a secondary filter derived from bill and research dates +- `effective_year` or `tax_year` should remain separate policy metadata + +Keep `state` temporarily for compatibility if needed, but stop relying on: + +- `state = "all"` as the main federal representation +- `relevant_states` as the main way to model federal content + +`relevant_states` is still useful, but as targeting metadata rather than the core federal identity. + +## Refactor Sequence + +### Phase 1 + +- update product copy +- add a federal destination in the UI +- keep state pages and the map intact + +### Phase 2 + +- introduce jurisdiction-aware schema fields +- backfill state rows +- define a federal ingestion source abstraction + +### Phase 3 + +- reduce [src/data/states.js](/Users/pavelmakarchuk/state-research-tracker/src/data/states.js) to display metadata +- move session and jurisdiction truth into data-driven structures + +### Phase 4 + +- add shared bill/session views if user behavior shows they are valuable + +## Prototype On This Branch + +This branch now reflects the first architectural step: + +- [src/App.jsx](/Users/pavelmakarchuk/state-research-tracker/src/App.jsx) supports a first-class `/federal` route +- [src/components/FederalPanel.jsx](/Users/pavelmakarchuk/state-research-tracker/src/components/FederalPanel.jsx) provides a federal workspace +- [src/components/StateSearchCombobox.jsx](/Users/pavelmakarchuk/state-research-tracker/src/components/StateSearchCombobox.jsx) can navigate to either a state or federal +- [src/context/DataContext.jsx](/Users/pavelmakarchuk/state-research-tracker/src/context/DataContext.jsx) now exposes federal bill/research helpers alongside state helpers + +This is the right test. It changes the product structure without discarding the state-centric workflow that users actually want. diff --git a/src/App.jsx b/src/App.jsx index 967f301..0eeb0b2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import Breadcrumb from "./components/Breadcrumb"; import StateSearchCombobox from "./components/StateSearchCombobox"; import { RecentActivitySidebar } from "./components/BillActivityFeed"; +const FederalPanel = lazy(() => import("./components/FederalPanel")); const StatePanel = lazy(() => import("./components/StatePanel")); const ReformAnalyzer = lazy(() => import("./components/reform/ReformAnalyzer")); import { useData } from "./context/DataContext"; @@ -11,6 +12,11 @@ import { stateData } from "./data/states"; import { colors, mapColors, typography, spacing } from "./designTokens"; import { track } from "./lib/analytics"; import { BASE_PATH } from "./lib/basePath"; +import { + FEDERAL_JURISDICTION, + isFederalJurisdiction, + isStateJurisdiction, +} from "./lib/jurisdictions"; function parsePath() { // Support old hash URLs for backward compat @@ -18,11 +24,15 @@ function parsePath() { // Strip BASE_PATH prefix before parsing const raw = hash || window.location.pathname; const path = (BASE_PATH ? raw.replace(BASE_PATH, "") : raw).replace(/^\//, ""); - if (!path) return { state: null, billId: null }; + if (!path) return { jurisdiction: null, billId: null }; const parts = path.split("/"); - const state = parts[0].toUpperCase(); + const segment = parts[0]; + const state = segment.toUpperCase(); const billId = parts[1] || null; - return { state: stateData[state] ? state : null, billId }; + if (segment.toLowerCase() === FEDERAL_JURISDICTION) { + return { jurisdiction: FEDERAL_JURISDICTION, billId }; + } + return { jurisdiction: stateData[state] ? state : null, billId }; } function notifyParent(path) { @@ -47,8 +57,8 @@ function LoadingPlaceholder() { } function App() { - const { statesWithBills, getBillsForState } = useData(); - const [selectedState, setSelectedState] = useState(() => parsePath().state); + const { statesWithBills, getBillsForState, getFederalBills } = useData(); + const [selectedJurisdiction, setSelectedJurisdiction] = useState(() => parsePath().jurisdiction); const [billId, setBillId] = useState(() => parsePath().billId); const activeStates = useMemo( @@ -68,40 +78,44 @@ function App() { } }, []); - const handleStateSelect = useCallback((abbr) => { - setSelectedState(abbr); + const handleJurisdictionSelect = useCallback((jurisdiction) => { + setSelectedJurisdiction(jurisdiction); setBillId(null); - if (abbr) { - history.pushState(null, "", BASE_PATH + "/" + abbr); - notifyParent("/" + abbr); - track("state_selected", { state_abbr: abbr, state_name: stateData[abbr]?.name }); + if (jurisdiction) { + history.pushState(null, "", BASE_PATH + "/" + jurisdiction); + notifyParent("/" + jurisdiction); + if (isFederalJurisdiction(jurisdiction)) { + track("federal_selected", { jurisdiction }); + } else { + track("state_selected", { state_abbr: jurisdiction, state_name: stateData[jurisdiction]?.name }); + } } else { history.pushState(null, "", BASE_PATH + "/"); notifyParent("/"); } }, []); - const handleBillSelect = useCallback((stateAbbr, id) => { - setSelectedState(stateAbbr); + const handleBillSelect = useCallback((jurisdiction, id) => { + setSelectedJurisdiction(jurisdiction); setBillId(id); - history.pushState(null, "", `${BASE_PATH}/${stateAbbr}/${id}`); - notifyParent(`/${stateAbbr}/${id}`); + history.pushState(null, "", `${BASE_PATH}/${jurisdiction}/${id}`); + notifyParent(`/${jurisdiction}/${id}`); }, []); const handleNavigateHome = useCallback(() => { - handleStateSelect(null); - }, [handleStateSelect]); + handleJurisdictionSelect(null); + }, [handleJurisdictionSelect]); - const handleNavigateState = useCallback(() => { - if (selectedState) { - handleStateSelect(selectedState); + const handleNavigateJurisdiction = useCallback(() => { + if (selectedJurisdiction) { + handleJurisdictionSelect(selectedJurisdiction); } - }, [selectedState, handleStateSelect]); + }, [selectedJurisdiction, handleJurisdictionSelect]); useEffect(() => { const onPopState = () => { - const { state, billId: bid } = parsePath(); - setSelectedState(state); + const { jurisdiction, billId: bid } = parsePath(); + setSelectedJurisdiction(jurisdiction); setBillId(bid); const strippedPath = BASE_PATH ? window.location.pathname.replace(BASE_PATH, "") @@ -114,14 +128,20 @@ function App() { // Resolve bill for bill page const activeBill = useMemo(() => { - if (!selectedState || !billId) return null; - const bills = getBillsForState(selectedState); + if (!selectedJurisdiction || !billId) return null; + const bills = isFederalJurisdiction(selectedJurisdiction) + ? getFederalBills() + : getBillsForState(selectedJurisdiction); return bills.find((b) => b.id === billId) || null; - }, [selectedState, billId, getBillsForState]); + }, [selectedJurisdiction, billId, getBillsForState, getFederalBills]); // Determine view - const isBillPage = selectedState && billId && activeBill?.reformConfig; - const isStatePage = selectedState && !isBillPage; + const isBillPage = + isStateJurisdiction(selectedJurisdiction) && + selectedJurisdiction && + billId && + activeBill?.reformConfig; + const isJurisdictionPage = selectedJurisdiction && !isBillPage; return (
@@ -155,7 +175,7 @@ function App() { fontFamily: typography.fontFamily.primary, letterSpacing: "-0.02em", }}> - 2026 State Legislative Tracker + Tax and Transfer Bill Tracker

- PolicyEngine State Tax Research + State-first legislative tracking with a federal workspace

- + @@ -179,39 +199,44 @@ function App() { {isBillPage && (
}>
)} - {/* === State Page === */} - {isStatePage && ( + {/* === Jurisdiction Page === */} + {isJurisdictionPage && (
}> - handleBillSelect(selectedState, id)} - /> + {isFederalJurisdiction(selectedJurisdiction) ? ( + + ) : ( + handleBillSelect(selectedJurisdiction, id)} + /> + )}
)} {/* === Home Page === */} - {!selectedState && ( + {!selectedJurisdiction && ( <> {/* Intro */}
@@ -223,7 +248,7 @@ function App() { fontFamily: typography.fontFamily.primary, letterSpacing: "-0.02em", }}> - State Tax Policy Research + Track tax and transfer bills by jurisdiction

- Explore state legislative sessions and PolicyEngine analysis. Select a state to see tax changes, active bills, and related research. + Start with your state, or jump into federal. The app keeps the state workflow front and center while using one shared bill-analysis pipeline underneath.

+
+ + + The map remains the primary entry point for state legislation. + +
States with Published Analysis - +
)}
- +
- +
diff --git a/src/components/BillActivityFeed.jsx b/src/components/BillActivityFeed.jsx index c6a38f7..3eadce1 100644 --- a/src/components/BillActivityFeed.jsx +++ b/src/components/BillActivityFeed.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { supabase } from "../lib/supabase"; import { useData } from "../context/DataContext"; import { colors, typography, spacing } from "../designTokens"; +import { ALL_YEARS, matchesSessionScope, matchesYearFilter } from "../lib/sessionFilters"; const REQUEST_API_PATH = "/api/bill-analysis-request"; const MAILCHIMP_SUBSCRIBE_URL = @@ -774,13 +775,21 @@ function StageSummaryBar({ bills }) { const DEFAULT_VISIBLE = 5; -export function StateBillActivity({ stateAbbr, onBillSelect }) { +export function StateBillActivity({ stateAbbr, onBillSelect, sessionYearSet = null, selectedYear = ALL_YEARS }) { const { bills, loading } = useProcessedBills(stateAbbr); const { research } = useData(); const [expanded, setExpanded] = useState(false); const [actionBill, setActionBill] = useState(null); const [requestBill, setRequestBill] = useState(null); + const scopedBills = useMemo( + () => bills.filter((bill) => ( + matchesSessionScope(bill, sessionYearSet, "last_action_date") && + matchesYearFilter(bill, selectedYear, "last_action_date") + )), + [bills, sessionYearSet, selectedYear], + ); + const { analyzedBillIds, billToResearchId } = useMemo(() => { const ids = new Set(); const lookup = {}; @@ -800,11 +809,11 @@ export function StateBillActivity({ stateAbbr, onBillSelect }) { }, [research]); const unananalyzedBills = useMemo( - () => bills.filter((b) => { + () => scopedBills.filter((b) => { const norm = `${b.state}:${b.bill_number.replace(/\s+/g, "").replace(/^([A-Z]+)0+(\d)/, "$1$2").toUpperCase()}`; return !analyzedBillIds.has(norm); }), - [bills, analyzedBillIds], + [scopedBills, analyzedBillIds], ); if (loading || !unananalyzedBills.length) return null; diff --git a/src/components/Breadcrumb.jsx b/src/components/Breadcrumb.jsx index 769b96c..2050480 100644 --- a/src/components/Breadcrumb.jsx +++ b/src/components/Breadcrumb.jsx @@ -1,5 +1,6 @@ import { colors, typography, spacing } from "../designTokens"; import { stateData } from "../data/states"; +import { getJurisdictionLabel } from "../lib/jurisdictions"; const ArrowLeft = () => ( @@ -13,8 +14,10 @@ const ChevronRight = () => ( ); -export default function Breadcrumb({ stateAbbr, billLabel, onNavigateHome, onNavigateState }) { - const onBack = billLabel ? onNavigateState : onNavigateHome; +export default function Breadcrumb({ jurisdiction, billLabel, onNavigateHome, onNavigateJurisdiction }) { + const onBack = billLabel ? onNavigateJurisdiction : onNavigateHome; + const jurisdictionLabel = getJurisdictionLabel(jurisdiction, stateData); + return (