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/scripts/openstates_monitor.py b/scripts/openstates_monitor.py
index 9013664..5e59a14 100644
--- a/scripts/openstates_monitor.py
+++ b/scripts/openstates_monitor.py
@@ -108,7 +108,13 @@ def openstates_request(endpoint, params=None, max_retries=3):
url = f"{OPENSTATES_BASE_URL}{endpoint}"
for attempt in range(max_retries):
- response = requests.get(url, headers=headers, params=params or {})
+ try:
+ response = requests.get(url, headers=headers, params=params or {}, timeout=45)
+ except requests.RequestException as e:
+ wait = 5 * (attempt + 1)
+ print(f" Request failed ({e.__class__.__name__}), retrying in {wait}s...")
+ time.sleep(wait)
+ continue
if response.status_code == 429:
wait = 15 * (attempt + 1) # 15s, 30s, 45s
@@ -116,11 +122,17 @@ def openstates_request(endpoint, params=None, max_retries=3):
time.sleep(wait)
continue
+ if response.status_code in {500, 502, 503, 504}:
+ wait = 5 * (attempt + 1)
+ print(f" OpenStates {response.status_code}, retrying in {wait}s...")
+ time.sleep(wait)
+ continue
+
response.raise_for_status()
return response.json()
# Final attempt without retry
- response = requests.get(url, headers=headers, params=params or {})
+ response = requests.get(url, headers=headers, params=params or {}, timeout=45)
response.raise_for_status()
return response.json()
diff --git a/scripts/refresh_bill_status.py b/scripts/refresh_bill_status.py
index 4fda29c..cba5fcf 100644
--- a/scripts/refresh_bill_status.py
+++ b/scripts/refresh_bill_status.py
@@ -29,12 +29,22 @@
import json
import argparse
import time
+import re
+import difflib
import requests
+from datetime import datetime
# ============== Configuration ==============
OPENSTATES_API_KEY = os.environ.get("OPENSTATES_API_KEY")
OPENSTATES_BASE_URL = "https://v3.openstates.org"
+RECENT_CREATED_SINCE = f"{datetime.utcnow().year - 1}-01-01"
+
+STOPWORDS = {
+ "act", "bill", "state", "tax", "income", "credit", "credits", "reduction",
+ "increase", "expanded", "expansion", "child", "marriage", "penalty",
+ "elimination", "supplemental", "empire",
+}
# Legislative stage classification based on action classifications
# Order matters — later stages override earlier ones
@@ -90,6 +100,54 @@
"dead": "Dead/Withdrawn",
}
+BILL_NUMBER_RE = re.compile(r"\b(?!FY)([A-Z]{1,3}\.?\s*\d+(?:\s*S\d+)?)\b", re.I)
+
+
+class RateLimitExhaustedError(RuntimeError):
+ """Raised when OpenStates continues returning 429 after retries."""
+
+
+def normalize_bill_number(value):
+ """Normalize bill numbers across spacing and leading-zero variants."""
+ if not value:
+ return None
+
+ value = re.sub(r"\s+", "", value).replace(".", "").upper()
+ return re.sub(r"([A-Z]+)0+(\d)", r"\1\2", value)
+
+
+def normalize_text(value):
+ """Lowercase and strip punctuation for fuzzy title comparisons."""
+ return re.sub(r"[^a-z0-9 ]+", " ", (value or "").lower())
+
+
+def token_set(value):
+ """Tokenize bill titles while dropping generic legislative filler."""
+ tokens = set()
+ for token in normalize_text(value).split():
+ if len(token) <= 2 or token in STOPWORDS or token.isdigit():
+ continue
+ tokens.add(token)
+ return tokens
+
+
+def title_similarity_score(left, right):
+ """Return sequence and token-overlap similarity for two bill titles."""
+ left_norm = normalize_text(left)
+ right_norm = normalize_text(right)
+ ratio = difflib.SequenceMatcher(None, left_norm, right_norm).ratio()
+ left_tokens = token_set(left)
+ right_tokens = token_set(right)
+ overlap = len(left_tokens & right_tokens) / max(1, len(left_tokens | right_tokens))
+ return ratio, overlap
+
+
+def normalize_action_date(value):
+ """Collapse ISO timestamps to YYYY-MM-DD for stable comparison/storage."""
+ if not value:
+ return None
+ return str(value)[:10]
+
def openstates_request(endpoint, params=None, max_retries=3):
"""Make a request to the OpenStates API v3 with retry on rate limit."""
@@ -108,6 +166,12 @@ def openstates_request(endpoint, params=None, max_retries=3):
time.sleep(wait)
continue
+ if response.status_code in {500, 502, 503, 504}:
+ wait = 5 * (attempt + 1)
+ print(f" OpenStates {response.status_code}, retrying in {wait}s...")
+ time.sleep(wait)
+ continue
+
if response.status_code == 404:
return None
@@ -116,8 +180,14 @@ def openstates_request(endpoint, params=None, max_retries=3):
response = requests.get(url, headers=headers, params=params or {})
if response.status_code == 429:
- print(f" Still rate limited after {max_retries} retries, skipping")
- return None
+ raise RateLimitExhaustedError(
+ f"OpenStates rate limit exhausted after {max_retries} retries"
+ )
+ if response.status_code in {500, 502, 503, 504}:
+ raise requests.HTTPError(
+ f"OpenStates transient error persisted ({response.status_code})",
+ response=response,
+ )
response.raise_for_status()
return response.json()
@@ -155,39 +225,50 @@ def classify_stage(actions):
return stage
-def search_bill_on_openstates(state_name, bill_number):
+def search_bill_on_openstates(state_name, bill_number, title=""):
"""
Search for a bill by state + identifier on OpenStates.
Returns the bill detail with actions, or None.
"""
- # Clean bill number for search (e.g., "HB05133" -> "HB 5133", "SB0032" -> "SB 32")
clean_num = bill_number.strip()
+ target_norm = normalize_bill_number(clean_num)
params = {
"jurisdiction": state_name,
"q": clean_num,
- "per_page": 5,
+ "per_page": 8,
"include": "actions",
+ "sort": "updated_desc",
+ "created_since": RECENT_CREATED_SINCE,
}
data = openstates_request("/bills", params)
if not data or not data.get("results"):
return None
- # Find best match by identifier
+ candidates = []
for result in data["results"]:
- result_id = result.get("identifier", "").replace(" ", "").upper()
- search_id = clean_num.replace(" ", "").upper()
- # Strip leading zeros for comparison
- import re
- result_norm = re.sub(r'([A-Z]+)0*(\d+)', r'\1\2', result_id)
- search_norm = re.sub(r'([A-Z]+)0*(\d+)', r'\1\2', search_id)
+ result_norm = normalize_bill_number(result.get("identifier", ""))
+ if target_norm and result_norm != target_norm:
+ continue
+
+ ratio, overlap = title_similarity_score(title, result.get("title", ""))
+ latest_date = normalize_action_date(result.get("latest_action_date"))
+ recency_bonus = 20 if latest_date and latest_date >= RECENT_CREATED_SINCE else 0
+ score = ratio * 100 + overlap * 100 + recency_bonus
+ candidates.append((score, ratio, overlap, result))
- if result_norm == search_norm:
- return result
+ if not candidates:
+ return None
- # If no exact match, return first result as fallback
- return data["results"][0] if data["results"] else None
+ candidates.sort(key=lambda item: item[0], reverse=True)
+ _, ratio, overlap, result = candidates[0]
+
+ # Reject low-confidence title mismatches to avoid wrong-session collisions.
+ if title and ratio < 0.22 and overlap == 0:
+ return None
+
+ return result
def get_bill_detail(openstates_id):
@@ -269,6 +350,9 @@ def main():
skipped = 0
errors = 0
+ interrupted_by_rate_limit = False
+ resume_offset = None
+
for i, bill in enumerate(bills):
state = bill["state"]
bn = bill["bill_number"]
@@ -278,7 +362,7 @@ def main():
try:
# Search for the bill on OpenStates by state + bill number
- detail = search_bill_on_openstates(state_name, bn)
+ detail = search_bill_on_openstates(state_name, bn, bill.get("title", ""))
if not detail:
print("not found on OpenStates")
@@ -291,15 +375,19 @@ def main():
# Get latest action info
latest_action = detail.get("latest_action_description", "")
- latest_action_date = detail.get("latest_action_date", "") or None
+ latest_action_date = normalize_action_date(detail.get("latest_action_date", "") or None)
# Determine if anything changed
old_action = bill.get("last_action", "")
- old_date = bill.get("last_action_date", "")
+ old_date = normalize_action_date(bill.get("last_action_date", ""))
stage_label = STAGE_LABELS.get(stage, stage)
- if latest_action == old_action and latest_action_date == old_date:
+ if (
+ latest_action == old_action
+ and latest_action_date == old_date
+ and stage_label == (bill.get("status") or "")
+ ):
print(f"{stage_label} (no change)")
skipped += 1
else:
@@ -319,6 +407,11 @@ def main():
updated += 1
+ except RateLimitExhaustedError as e:
+ print(f"STOPPING: {e}")
+ interrupted_by_rate_limit = True
+ resume_offset = args.offset + i
+ break
except Exception as e:
print(f"ERROR: {e}")
errors += 1
@@ -332,6 +425,8 @@ def main():
print(f" Updated: {updated}")
print(f" No change: {skipped}")
print(f" Errors: {errors}")
+ if interrupted_by_rate_limit:
+ print(f" Resume with: --offset {resume_offset}")
return 0
diff --git a/src/App.jsx b/src/App.jsx
index 967f301..06bbe3f 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,20 +128,25 @@ 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 (
{/* Header */}
-
+
-
+
-
-
- 2026 State Legislative Tracker
-
-
- PolicyEngine State Tax Research
-
-
+
+ Bill Tracker
+
-
+
+
+ { if (selectedJurisdiction) handleJurisdictionSelect(null); }}
+ >
+ States
+
+ { if (!isFederalJurisdiction(selectedJurisdiction)) handleJurisdictionSelect(FEDERAL_JURISDICTION); }}
+ >
+ Federal
+
+
@@ -179,61 +202,58 @@ function App() {
{isBillPage && (
}>
)}
- {/* === State Page === */}
- {isStatePage && (
+ {/* === Jurisdiction Page === */}
+ {isJurisdictionPage && (
-
}>
- handleBillSelect(selectedState, id)}
- />
+ {isFederalJurisdiction(selectedJurisdiction) ? (
+
+ ) : (
+ handleBillSelect(selectedJurisdiction, id)}
+ />
+ )}
)}
{/* === Home Page === */}
- {!selectedState && (
+ {!selectedJurisdiction && (
<>
- {/* Intro */}
-
+
- State Tax Policy Research
+ Select a state to explore legislation
- Explore state legislative sessions and PolicyEngine analysis. Select a state to see tax changes, active bills, and related research.
+ Click a state on the map or use the search bar above.
@@ -259,8 +279,8 @@ function App() {
States with Published Analysis
-
+
)}
-
+
>
@@ -536,6 +556,18 @@ function QuickLinkCard({ href, title, description }) {
);
}
+// Nav Tab Component
+function NavTab({ active, onClick, children }) {
+ return (
+
+ {children}
+
+ );
+}
+
// Footer Link Component
function FooterLink({ href, children }) {
return (
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 (
Home
- {stateAbbr && (
+ {jurisdiction && (
<>
{billLabel ? (
e.currentTarget.style.textDecoration = "underline"}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = "none"}
>
- {stateData[stateAbbr]?.name || stateAbbr}
+ {jurisdictionLabel}
) : (
- {stateData[stateAbbr]?.name || stateAbbr}
+ {jurisdictionLabel}
)}
>
diff --git a/src/components/FederalPanel.jsx b/src/components/FederalPanel.jsx
new file mode 100644
index 0000000..22b61f9
--- /dev/null
+++ b/src/components/FederalPanel.jsx
@@ -0,0 +1,375 @@
+import { memo, useMemo, useState } from "react";
+import { useData } from "../context/DataContext";
+import ResearchCard from "./ResearchCard";
+import { colors, typography, spacing } from "../designTokens";
+import SessionFilterBar from "./SessionFilterBar";
+import {
+ ALL_ACTIVITY_SCOPE,
+ ALL_YEARS,
+ CURRENT_FEDERAL_SESSION,
+ CURRENT_SCOPE,
+ buildSessionYearSet,
+ collectYears,
+ matchesSessionScope,
+ matchesYearFilter,
+} from "../lib/sessionFilters";
+
+const CapitolIcon = () => (
+
+
+
+
+
+
+);
+
+const LinkIcon = () => (
+
+
+
+);
+
+function SectionHeader({ children }) {
+ return (
+ {children}
+ );
+}
+
+const FederalBillCard = memo(({ bill }) => (
+
+
+
+
+
+
{bill.title}
+ {bill.description && (
+
+ {bill.description}
+
+ )}
+
+
+ {bill.reformConfig ? "Modeled" : "Tracked"}
+
+ {bill.url && (
+
+ Open bill
+
+ )}
+
+
+
+));
+
+FederalBillCard.displayName = "FederalBillCard";
+
+const FederalPanel = memo(() => {
+ const { getFederalBills, getFederalResearch } = useData();
+ const [selectedScope, setSelectedScope] = useState(CURRENT_SCOPE);
+ const [selectedYear, setSelectedYear] = useState(ALL_YEARS);
+
+ const bills = getFederalBills();
+ const research = getFederalResearch();
+ const sessionYearSet = buildSessionYearSet(selectedScope, CURRENT_FEDERAL_SESSION.years);
+ const availableYears = useMemo(
+ () => collectYears(bills, research, CURRENT_FEDERAL_SESSION.years),
+ [bills, research],
+ );
+ const filteredBills = bills.filter(
+ (bill) => matchesSessionScope(bill, sessionYearSet) && matchesYearFilter(bill, selectedYear),
+ );
+ const filteredResearch = research.filter(
+ (item) => matchesSessionScope(item, sessionYearSet) && matchesYearFilter(item, selectedYear),
+ );
+
+ const published = filteredResearch.filter((item) => item.status === "published");
+ const inProgress = filteredResearch.filter((item) => item.status === "in_progress");
+ const planned = filteredResearch.filter((item) => item.status === "planned");
+ const tools = published
+ .filter((item) => item.federalToolOrder !== undefined)
+ .sort((a, b) => a.federalToolOrder - b.federalToolOrder);
+ const publishedResearch = published
+ .filter((item) => item.federalToolOrder === undefined)
+ .sort((a, b) => (b.date || "").localeCompare(a.date || ""));
+ const scopeOptions = [
+ {
+ id: CURRENT_SCOPE,
+ label: CURRENT_FEDERAL_SESSION.label,
+ description: CURRENT_FEDERAL_SESSION.description,
+ },
+ {
+ id: ALL_ACTIVITY_SCOPE,
+ label: "All tracked activity",
+ description: "Shows federal bills and research across all available activity years.",
+ },
+ ];
+ const filterSummary = selectedYear === ALL_YEARS
+ ? `Viewing ${selectedScope === CURRENT_SCOPE ? CURRENT_FEDERAL_SESSION.label : "all tracked federal activity"}.`
+ : `Viewing federal activity for ${selectedYear}.`;
+
+ return (
+
+
+
Federal
+
+
+
+ 119th Congress
+
+
+ Bills, tools, and analysis
+
+
+
+
+
+
+
+ {filteredBills.length > 0 && (
+
+
Tracked Federal Bills
+
+ {filteredBills.map((bill) => (
+
+ ))}
+
+
+ )}
+
+ {inProgress.length > 0 && (
+
+
Analysis In Progress
+
+ {inProgress.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {publishedResearch.length > 0 && (
+
+
Published Research
+
+ {publishedResearch.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {tools.length > 0 && (
+
+
Federal Tools
+
+ {tools.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {planned.length > 0 && (
+
+
Planned
+
+ {planned.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {filteredBills.length === 0 && publishedResearch.length === 0 && inProgress.length === 0 && planned.length === 0 && tools.length === 0 && (
+
+
+ No federal activity matches the selected Congress and year filters yet.
+
+
+ This panel is wired into the same research pipeline and is ready for federal bill ingestion.
+
+
+ )}
+
+
+
+
+ Need federal bill analysis?
+
+
+ The same scoring and modeling workflow can support federal tax and transfer legislation as we add a federal source.
+
+
+ Get in Contact
+
+
+
+
+
+ );
+});
+
+FederalPanel.displayName = "FederalPanel";
+
+export default FederalPanel;
diff --git a/src/components/SessionFilterBar.jsx b/src/components/SessionFilterBar.jsx
new file mode 100644
index 0000000..30aeb5d
--- /dev/null
+++ b/src/components/SessionFilterBar.jsx
@@ -0,0 +1,123 @@
+import { colors, typography, spacing } from "../designTokens";
+import { ALL_YEARS } from "../lib/sessionFilters";
+
+function FilterButton({ active, children, onClick }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default function SessionFilterBar({
+ scopeLabel,
+ scopeOptions,
+ selectedScope,
+ onScopeChange,
+ yearOptions,
+ selectedYear,
+ onYearChange,
+ summary,
+}) {
+ return (
+
+
+
+ {scopeLabel}
+
+
+ {scopeOptions.map((option) => (
+ onScopeChange(option.id)}
+ >
+ {option.label}
+
+ ))}
+
+ {scopeOptions.find((option) => option.id === selectedScope)?.description && (
+
+ {scopeOptions.find((option) => option.id === selectedScope)?.description}
+
+ )}
+
+
+
+
+ Activity Year
+
+
+ onYearChange(ALL_YEARS)}
+ >
+ All years
+
+ {yearOptions.map((year) => (
+ onYearChange(year)}
+ >
+ {year}
+
+ ))}
+
+
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+ );
+}
diff --git a/src/components/StatePanel.jsx b/src/components/StatePanel.jsx
index 9d1d624..f8dea54 100644
--- a/src/components/StatePanel.jsx
+++ b/src/components/StatePanel.jsx
@@ -1,10 +1,21 @@
-import { memo } from "react";
+import { memo, useEffect, useState } from "react";
import { stateData } from "../data/states";
import { useData } from "../context/DataContext";
import ResearchCard from "./ResearchCard";
import { StateBillActivity, StageBadge, useProcessedBills } from "./BillActivityFeed";
import { colors, typography, spacing } from "../designTokens";
import { track } from "../lib/analytics";
+import SessionFilterBar from "./SessionFilterBar";
+import {
+ ALL_ACTIVITY_SCOPE,
+ ALL_YEARS,
+ CURRENT_SCOPE,
+ buildSessionYearSet,
+ collectYears,
+ extractYearsFromText,
+ matchesSessionScope,
+ matchesYearFilter,
+} from "../lib/sessionFilters";
const CalendarIcon = () => (
@@ -53,25 +64,47 @@ const StatePanel = memo(({ stateAbbr, onBillSelect }) => {
const state = stateData[stateAbbr];
const { getBillsForState, getResearchForState } = useData();
const { bills: pipelineBills } = useProcessedBills(stateAbbr);
+ const [selectedScope, setSelectedScope] = useState(CURRENT_SCOPE);
+ const [selectedYear, setSelectedYear] = useState(ALL_YEARS);
+
+ useEffect(() => {
+ setSelectedScope(CURRENT_SCOPE);
+ setSelectedYear(ALL_YEARS);
+ }, [stateAbbr]);
+
+ if (!state) return null;
+
+ const bills = getBillsForState(stateAbbr);
+ const research = getResearchForState(stateAbbr);
+ const sessionYears = extractYearsFromText(state.session.dates);
+ const sessionYearSet = buildSessionYearSet(selectedScope, sessionYears);
+ const availableYears = collectYears(bills, research, pipelineBills, sessionYears);
+
+ const filteredBills = bills.filter(
+ (bill) => matchesSessionScope(bill, sessionYearSet) && matchesYearFilter(bill, selectedYear),
+ );
+ const filteredResearch = research.filter(
+ (item) => matchesSessionScope(item, sessionYearSet) && matchesYearFilter(item, selectedYear),
+ );
+ const filteredPipelineBills = pipelineBills.filter(
+ (bill) =>
+ matchesSessionScope(bill, sessionYearSet, "last_action_date") &&
+ matchesYearFilter(bill, selectedYear, "last_action_date"),
+ );
// Build lookup: normalized bill number -> stage from processed_bills
// Normalize: strip spaces, strip leading zeros after letter prefix (HB0290 -> HB290, S04487 -> S4487)
const normalizeBillNum = (s) => s.replace(/\s+/g, "").toUpperCase().replace(/([A-Z]+)0+(\d)/, "$1$2");
const stageByBill = {};
- for (const pb of pipelineBills) {
+ for (const pb of filteredPipelineBills) {
if (!pb.status) continue;
stageByBill[normalizeBillNum(pb.bill_number)] = pb.status;
}
- if (!state) return null;
-
- const bills = getBillsForState(stateAbbr);
- const research = getResearchForState(stateAbbr);
-
// Separate research by status
- const published = research.filter((r) => r.status === "published");
- const inProgress = research.filter((r) => r.status === "in_progress");
- const planned = research.filter((r) => r.status === "planned");
+ const published = filteredResearch.filter((r) => r.status === "published");
+ const inProgress = filteredResearch.filter((r) => r.status === "in_progress");
+ const planned = filteredResearch.filter((r) => r.status === "planned");
// Sort by date (newest first)
const sortByDate = (items) => [...items].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
@@ -79,58 +112,75 @@ const StatePanel = memo(({ stateAbbr, onBillSelect }) => {
// Separate state-specific from federal
const stateSpecific = sortByDate(published.filter((r) => r.state === stateAbbr));
const federal = sortByDate(published.filter((r) => r.state === "all" || (r.relevantStates && r.relevantStates.includes(stateAbbr))));
+ const scopeOptions = [
+ {
+ id: CURRENT_SCOPE,
+ label: "Current session",
+ description: sessionYears.length
+ ? `${state.session.dates}${state.session.carryover ? " • includes carryover years" : ""}`
+ : "Uses the years covered by the current legislative session.",
+ },
+ {
+ id: ALL_ACTIVITY_SCOPE,
+ label: "All tracked activity",
+ description: "Shows tracked bills and research across all available activity years.",
+ },
+ ];
+ const activityHeading = selectedScope === CURRENT_SCOPE ? "Current Session Activity" : "Tracked Legislative Activity";
+ const filterSummary = selectedYear === ALL_YEARS
+ ? `Viewing ${selectedScope === CURRENT_SCOPE ? "session years" : "all available years"} for ${state.name}.`
+ : `Viewing ${selectedYear} activity for ${state.name}.`;
return (
{/* Header */}
-
-
{state.name}
-
+
{state.name}
+
+
+
+ {state.session.dates}
+
+ {state.session.carryover !== undefined && (
-
- {state.session.dates}
+ }}
+ title={state.session.carryover ? "Bills from 2025 carry over to 2026" : "Bills do not carry over from 2025"}
+ >
+ {state.session.carryover ? "Carryover" : "No carryover"}
- {state.session.carryover !== undefined && (
-
- {state.session.carryover ? "Carryover" : "No carryover"}
-
- )}
-
+ )}
@@ -142,13 +192,22 @@ const StatePanel = memo(({ stateAbbr, onBillSelect }) => {
border: `1px solid ${colors.border.light}`,
borderTop: "none",
}}>
+
- {/* 2026 Legislative Activity */}
- {(bills.length > 0 || state.taxChanges?.length > 0) && (
+ {(filteredBills.length > 0 || (selectedScope === CURRENT_SCOPE && state.taxChanges?.length > 0)) && (
-
2026 Legislative Activity
-
3 ? "1fr 1fr" : "1fr", gap: spacing.sm }}>
- {state.taxChanges?.map((change, i) => (
+
{activityHeading}
+
3 ? "1fr 1fr" : "1fr", gap: spacing.sm }}>
+ {selectedScope === CURRENT_SCOPE && state.taxChanges?.map((change, i) => (
{
))}
- {bills.map((bill, i) => (
+ {filteredBills.map((bill, i) => (
{
@@ -338,7 +397,12 @@ const StatePanel = memo(({ stateAbbr, onBillSelect }) => {
{/* Tracked Bills from Pipeline */}
-
+
{/* In Progress Research */}
@@ -393,7 +457,7 @@ const StatePanel = memo(({ stateAbbr, onBillSelect }) => {
)}
{/* No activity message */}
- {stateSpecific.length === 0 && inProgress.length === 0 && !state.taxChanges?.length && bills.length === 0 && (
+ {stateSpecific.length === 0 && inProgress.length === 0 && planned.length === 0 && federal.length === 0 && filteredPipelineBills.length === 0 && filteredBills.length === 0 && !(selectedScope === CURRENT_SCOPE && state.taxChanges?.length) && (
{
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.body,
}}>
- No major tax legislation currently tracked for {state.name}.
+ No tracked activity matches the selected session and year filters for {state.name}.
{
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.body,
}}>
- Session: {state.session.dates}
+ Session window: {state.session.dates}
)}
diff --git a/src/components/StateSearchCombobox.jsx b/src/components/StateSearchCombobox.jsx
index 513dc01..90626c1 100644
--- a/src/components/StateSearchCombobox.jsx
+++ b/src/components/StateSearchCombobox.jsx
@@ -1,11 +1,22 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { stateData } from "../data/states";
import { colors, typography, spacing } from "../designTokens";
+import { FEDERAL_JURISDICTION } from "../lib/jurisdictions";
-const ALL_STATES = Object.entries(stateData).map(([abbr, s]) => ({
- abbr,
- name: s.name,
-}));
+const ALL_JURISDICTIONS = [
+ {
+ value: FEDERAL_JURISDICTION,
+ code: "US",
+ name: "Federal",
+ kind: "federal",
+ },
+ ...Object.entries(stateData).map(([abbr, s]) => ({
+ value: abbr,
+ code: abbr,
+ name: s.name,
+ kind: "state",
+ })),
+];
export default function StateSearchCombobox({ onSelect, statesWithBills }) {
const [query, setQuery] = useState("");
@@ -15,14 +26,14 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
const containerRef = useRef(null);
const filtered = query
- ? ALL_STATES.filter(
- (s) =>
- s.name.toLowerCase().startsWith(query.toLowerCase()) ||
- s.abbr.toLowerCase().startsWith(query.toLowerCase()),
+ ? ALL_JURISDICTIONS.filter(
+ (item) =>
+ item.name.toLowerCase().startsWith(query.toLowerCase()) ||
+ item.code.toLowerCase().startsWith(query.toLowerCase()) ||
+ item.value.toLowerCase().startsWith(query.toLowerCase()),
)
- : ALL_STATES;
+ : ALL_JURISDICTIONS;
- // Close on outside click
useEffect(() => {
if (!open) return;
const handleMouseDown = (e) => {
@@ -34,7 +45,6 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [open]);
- // Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const item = listRef.current.children[activeIndex];
@@ -42,11 +52,11 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
}, [activeIndex]);
const select = useCallback(
- (abbr) => {
+ (value) => {
setQuery("");
setOpen(false);
setActiveIndex(-1);
- onSelect(abbr);
+ onSelect(value);
},
[onSelect],
);
@@ -72,7 +82,7 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && filtered[activeIndex]) {
- select(filtered[activeIndex].abbr);
+ select(filtered[activeIndex].value);
}
break;
case "Escape":
@@ -83,7 +93,7 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
}
};
- const billCount = (abbr) => statesWithBills[abbr] || 0;
+ const billCount = (item) => (item.kind === "state" ? statesWithBills[item.value] || 0 : 0);
return (
@@ -98,10 +108,9 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
backgroundColor: colors.background.secondary,
padding: `${spacing.xs} ${spacing.md}`,
transition: "border-color 0.15s ease",
- width: "160px",
+ width: "220px",
}}
>
- {/* Magnifying glass */}
= 0 && filtered[activeIndex]
- ? `state-option-${filtered[activeIndex].abbr}`
+ ? `state-option-${filtered[activeIndex].value}`
: undefined
}
aria-autocomplete="list"
- aria-label="Search states"
- placeholder="Search states"
+ aria-label="Search states or federal"
+ placeholder="Search state or federal"
value={query}
onChange={(e) => {
setQuery(e.target.value);
@@ -181,19 +190,19 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
fontFamily: typography.fontFamily.body,
}}
>
- No states found
+ No jurisdictions found
) : (
- filtered.map((s, i) => {
- const count = billCount(s.abbr);
+ filtered.map((item, i) => {
+ const count = billCount(item);
const isActive = i === activeIndex;
return (
select(s.abbr)}
+ onClick={() => select(item.value)}
onMouseEnter={() => setActiveIndex(i)}
style={{
display: "flex",
@@ -202,9 +211,7 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
padding: `${spacing.sm} ${spacing.md}`,
borderRadius: spacing.radius.md,
cursor: "pointer",
- backgroundColor: isActive
- ? colors.background.secondary
- : "transparent",
+ backgroundColor: isActive ? colors.background.secondary : "transparent",
transition: "background-color 0.1s ease",
}}
>
@@ -222,9 +229,9 @@ export default function StateSearchCombobox({ onSelect, statesWithBills }) {
marginRight: spacing.sm,
}}
>
- {s.abbr}
+ {item.code}
- {s.name}
+ {item.name}
{count > 0 && (
{
const counts = {};
for (const item of research) {
- if (item.type === 'bill' && item.status !== 'in_review' && item.state) {
+ if (
+ item.type === 'bill' &&
+ item.status !== 'in_review' &&
+ item.state &&
+ item.state !== 'all' &&
+ item.state !== 'federal'
+ ) {
counts[item.state] = (counts[item.state] || 0) + 1;
}
}
@@ -101,25 +107,13 @@ export function DataProvider({ children }) {
const getBillsForState = (stateAbbr) => {
return research
.filter(item => item.state === stateAbbr && item.type === 'bill' && item.status !== 'in_review')
- .map(item => {
- const impact = reformImpacts[item.id];
- const description = getDescription(item.id) || item.description;
- return {
- id: item.id,
- bill: extractBillNumber(item.id, item.title),
- title: item.title,
- description: description,
- url: item.url,
- status: formatStatus(item.status),
- reformConfig: impact?.reformParams ? {
- id: item.id,
- label: item.title,
- description: description,
- reform: impact.reformParams,
- } : null,
- impact: impact,
- };
- });
+ .map(item => mapBillItem(item, reformImpacts));
+ };
+
+ const getFederalBills = () => {
+ return research
+ .filter(item => isFederalItem(item) && item.type === 'bill' && item.status !== 'in_review')
+ .map(item => mapBillItem(item, reformImpacts));
};
// Get research for a state (excluding type === 'bill')
@@ -132,21 +126,15 @@ export function DataProvider({ children }) {
if (item.state === 'all') return true;
if (item.relevant_states?.includes(stateAbbr)) return true;
return false;
- }).map(item => ({
- id: item.id,
- state: item.state,
- type: item.type,
- status: item.status,
- title: item.title,
- url: item.url,
- description: item.description,
- date: item.date,
- author: item.author,
- keyFindings: item.key_findings,
- tags: item.tags,
- relevantStates: item.relevant_states,
- federalToolOrder: item.federal_tool_order,
- }));
+ }).map(mapResearchItem);
+ };
+
+ const getFederalResearch = () => {
+ return research.filter(item => {
+ if (item.status === 'in_review') return false;
+ if (item.type === 'bill') return false;
+ return isFederalItem(item);
+ }).map(mapResearchItem);
};
// Get impact for a bill
@@ -160,7 +148,9 @@ export function DataProvider({ children }) {
error,
statesWithBills,
getBillsForState,
+ getFederalBills,
getResearchForState,
+ getFederalResearch,
getImpact,
}}>
{children}
@@ -191,6 +181,51 @@ function extractBillNumber(id, title) {
return id.toUpperCase();
}
+function isFederalItem(item) {
+ return item.state === 'all' || item.state === 'federal' || item.jurisdiction_code === 'US';
+}
+
+function mapBillItem(item, reformImpacts) {
+ const impact = reformImpacts[item.id];
+ const description = getDescription(item.id) || item.description;
+ return {
+ id: item.id,
+ bill: extractBillNumber(item.id, item.title),
+ title: item.title,
+ description: description,
+ url: item.url,
+ date: item.date,
+ status: formatStatus(item.status),
+ sessionName: item.session_name,
+ reformConfig: impact?.reformParams ? {
+ id: item.id,
+ label: item.title,
+ description: description,
+ reform: impact.reformParams,
+ } : null,
+ impact: impact,
+ };
+}
+
+function mapResearchItem(item) {
+ return {
+ id: item.id,
+ state: item.state,
+ type: item.type,
+ status: item.status,
+ title: item.title,
+ url: item.url,
+ description: item.description,
+ date: item.date,
+ sessionName: item.session_name,
+ author: item.author,
+ keyFindings: item.key_findings,
+ tags: item.tags,
+ relevantStates: item.relevant_states,
+ federalToolOrder: item.federal_tool_order,
+ };
+}
+
function formatStatus(status) {
const map = {
published: 'Published',
diff --git a/src/index.css b/src/index.css
index 65003e2..57296ed 100644
--- a/src/index.css
+++ b/src/index.css
@@ -181,6 +181,39 @@ input:focus-visible {
background: linear-gradient(90deg, #2C7A7B 0%, #38B2AC 50%, #0EA5E9 100%);
}
+/* Navigation tabs */
+.app-nav-tab {
+ padding: 8px 16px 12px;
+ border: none;
+ background: transparent;
+ color: var(--pe-gray-500);
+ font-size: 14px;
+ font-weight: 500;
+ font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ cursor: pointer;
+ position: relative;
+}
+
+.app-nav-tab:hover {
+ color: var(--pe-gray-700);
+}
+
+.app-nav-tab--active {
+ color: var(--pe-primary-600);
+ font-weight: 600;
+}
+
+.app-nav-tab--active::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--pe-primary-600);
+ border-radius: 3px 3px 0 0;
+}
+
/* Button styles */
.btn-primary {
background: linear-gradient(135deg, #2C7A7B 0%, #319795 100%);
diff --git a/src/lib/jurisdictions.js b/src/lib/jurisdictions.js
new file mode 100644
index 0000000..81e7164
--- /dev/null
+++ b/src/lib/jurisdictions.js
@@ -0,0 +1,16 @@
+export const FEDERAL_JURISDICTION = "federal";
+
+export function isFederalJurisdiction(jurisdiction) {
+ return jurisdiction === FEDERAL_JURISDICTION;
+}
+
+export function isStateJurisdiction(jurisdiction) {
+ return Boolean(jurisdiction) && !isFederalJurisdiction(jurisdiction);
+}
+
+export function getJurisdictionLabel(jurisdiction, stateData) {
+ if (isFederalJurisdiction(jurisdiction)) {
+ return "Federal";
+ }
+ return stateData[jurisdiction]?.name || jurisdiction;
+}
diff --git a/src/lib/sessionFilters.js b/src/lib/sessionFilters.js
new file mode 100644
index 0000000..2179601
--- /dev/null
+++ b/src/lib/sessionFilters.js
@@ -0,0 +1,52 @@
+export const ALL_YEARS = "all";
+export const CURRENT_SCOPE = "current";
+export const ALL_ACTIVITY_SCOPE = "all_activity";
+
+export const CURRENT_FEDERAL_SESSION = {
+ id: CURRENT_SCOPE,
+ label: "119th Congress",
+ description: "January 3, 2025 to January 3, 2027",
+ years: ["2026", "2025"],
+};
+
+export function extractYearsFromText(text) {
+ if (!text) return [];
+ const matches = text.match(/\b20\d{2}\b/g) || [];
+ return sortYearsDesc(Array.from(new Set(matches)));
+}
+
+export function extractYearFromDate(dateStr) {
+ if (!dateStr) return null;
+ const match = String(dateStr).match(/\b(20\d{2})\b/);
+ return match ? match[1] : null;
+}
+
+export function sortYearsDesc(years) {
+ return [...years].sort((a, b) => Number(b) - Number(a));
+}
+
+export function collectYears(...itemGroups) {
+ const years = new Set();
+ for (const group of itemGroups) {
+ for (const item of group || []) {
+ const year = typeof item === "string" ? item : extractYearFromDate(item?.date || item?.last_action_date);
+ if (year) years.add(year);
+ }
+ }
+ return sortYearsDesc(Array.from(years));
+}
+
+export function buildSessionYearSet(selectedScope, sessionYears) {
+ return selectedScope === CURRENT_SCOPE ? new Set(sessionYears) : null;
+}
+
+export function matchesYearFilter(item, selectedYear, dateField = "date") {
+ if (!selectedYear || selectedYear === ALL_YEARS) return true;
+ return extractYearFromDate(item?.[dateField]) === selectedYear;
+}
+
+export function matchesSessionScope(item, sessionYearSet, dateField = "date") {
+ if (!sessionYearSet) return true;
+ const year = extractYearFromDate(item?.[dateField]);
+ return year ? sessionYearSet.has(year) : false;
+}