diff --git a/ROADMAP.md b/ROADMAP.md
index 102a257..e9233e4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -56,6 +56,7 @@
- [x] Weekly digest — Monday 8:30 AM UTC; 7-day summary of followed bill activity via ntfy + RSS
- [x] Change-driven alerts — `categorize_action()` maps action text to 6 named categories; all three follow types (bill, sponsor, topic) covered
- [x] Granular per-mode alert filters — 8 independently toggleable alert types per follow mode (Follow / Pocket Veto / Pocket Boost); preset defaults; tabbed UI with per-tab save
+- [x] Roll-call votes — `bill_votes` + `member_vote_positions` tables; on-demand fetch from Congress.gov `/votes` endpoint; VotePanel on bill detail shows yea/nay bar + followed member positions
### UX & Polish
- [x] Party badges — solid red/blue/slate, readable in light and dark mode
@@ -71,7 +72,7 @@
### Phase 4 — Accountability
-- [ ] **Votes & Committees** — fetch roll-call votes from Congress.gov into a `bill_votes` table. UI: vote results filterable by followed members and topics; timeline entries for committee actions.
+- [ ] **Vote History & Timeline** — surface roll-call votes in the action timeline; add a "Votes" filter to the bills list so users can find bills that have had floor votes.
- [ ] **Member Effectiveness Score** — nightly Celery task; transparent formula: sponsored bills, bills advanced through stages, co-sponsorships, committee participation, bills enacted. Stored in `member_scores`. Displayed on member profile with formula explanation.
- [ ] **Representation Alignment View** — for each followed member, show how their votes and actions align with the user's followed topics. Neutral presentation — no scorecard framing.
@@ -107,6 +108,6 @@ v1.0 ships when the following are all live:
- [x] Public browsing (no account required to read)
- [x] Multi-user auth with admin panel
- [ ] Member effectiveness score
-- [ ] Roll-call vote data
+- [x] Roll-call vote data
All other items above are post-v1.0.
diff --git a/backend/alembic/versions/0017_add_bill_votes.py b/backend/alembic/versions/0017_add_bill_votes.py
new file mode 100644
index 0000000..db8beca
--- /dev/null
+++ b/backend/alembic/versions/0017_add_bill_votes.py
@@ -0,0 +1,63 @@
+"""Add bill_votes and member_vote_positions tables
+
+Revision ID: 0017
+Revises: 0016
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0017"
+down_revision = "0016"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "bill_votes",
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column("bill_id", sa.String, sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
+ sa.Column("congress", sa.Integer, nullable=False),
+ sa.Column("chamber", sa.String(50), nullable=False),
+ sa.Column("session", sa.Integer, nullable=False),
+ sa.Column("roll_number", sa.Integer, nullable=False),
+ sa.Column("question", sa.Text),
+ sa.Column("description", sa.Text),
+ sa.Column("vote_date", sa.Date),
+ sa.Column("yeas", sa.Integer),
+ sa.Column("nays", sa.Integer),
+ sa.Column("not_voting", sa.Integer),
+ sa.Column("result", sa.String(200)),
+ sa.Column("source_url", sa.String),
+ sa.Column("fetched_at", sa.DateTime(timezone=True)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
+ )
+ op.create_index("ix_bill_votes_bill_id", "bill_votes", ["bill_id"])
+ op.create_unique_constraint(
+ "uq_bill_votes_roll",
+ "bill_votes",
+ ["congress", "chamber", "session", "roll_number"],
+ )
+
+ op.create_table(
+ "member_vote_positions",
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column("vote_id", sa.Integer, sa.ForeignKey("bill_votes.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("bioguide_id", sa.String, sa.ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True),
+ sa.Column("member_name", sa.String(200)),
+ sa.Column("party", sa.String(50)),
+ sa.Column("state", sa.String(10)),
+ sa.Column("position", sa.String(50), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
+ )
+ op.create_index("ix_member_vote_positions_vote_id", "member_vote_positions", ["vote_id"])
+ op.create_index("ix_member_vote_positions_bioguide_id", "member_vote_positions", ["bioguide_id"])
+
+
+def downgrade():
+ op.drop_index("ix_member_vote_positions_bioguide_id", "member_vote_positions")
+ op.drop_index("ix_member_vote_positions_vote_id", "member_vote_positions")
+ op.drop_table("member_vote_positions")
+ op.drop_index("ix_bill_votes_bill_id", "bill_votes")
+ op.drop_constraint("uq_bill_votes_roll", "bill_votes")
+ op.drop_table("bill_votes")
diff --git a/backend/app/api/bills.py b/backend/app/api/bills.py
index 756a97f..3ccd347 100644
--- a/backend/app/api/bills.py
+++ b/backend/app/api/bills.py
@@ -12,6 +12,7 @@ from app.schemas.schemas import (
BillDetailSchema,
BillSchema,
BillActionSchema,
+ BillVoteSchema,
NewsArticleSchema,
PaginatedResponse,
TrendScoreSchema,
@@ -202,6 +203,32 @@ async def get_bill_trend(bill_id: str, days: int = Query(30, ge=7, le=365), db:
return result.scalars().all()
+@router.get("/{bill_id}/votes", response_model=list[BillVoteSchema])
+async def get_bill_votes_endpoint(bill_id: str, db: AsyncSession = Depends(get_db)):
+ from app.models.vote import BillVote
+ from sqlalchemy.orm import selectinload
+
+ result = await db.execute(
+ select(BillVote)
+ .where(BillVote.bill_id == bill_id)
+ .options(selectinload(BillVote.positions))
+ .order_by(desc(BillVote.vote_date))
+ )
+ votes = result.scalars().unique().all()
+
+ # Trigger background fetch if no votes are stored yet
+ if not votes:
+ bill = await db.get(Bill, bill_id)
+ if bill:
+ try:
+ from app.workers.vote_fetcher import fetch_bill_votes
+ fetch_bill_votes.delay(bill_id)
+ except Exception:
+ pass
+
+ return votes
+
+
@router.post("/{bill_id}/draft-letter", response_model=DraftLetterResponse)
async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSession = Depends(get_db)):
from app.models.setting import AppSetting
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 9370551..8ed51c5 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -11,6 +11,7 @@ from app.models.setting import AppSetting
from app.models.trend import TrendScore
from app.models.committee import Committee, CommitteeBill
from app.models.user import User
+from app.models.vote import BillVote, MemberVotePosition
__all__ = [
"Bill",
@@ -18,12 +19,14 @@ __all__ = [
"BillDocument",
"BillBrief",
"BillNote",
+ "BillVote",
"Collection",
"CollectionBill",
"Follow",
"Member",
"MemberTrendScore",
"MemberNewsArticle",
+ "MemberVotePosition",
"NewsArticle",
"NotificationEvent",
"AppSetting",
diff --git a/backend/app/models/vote.py b/backend/app/models/vote.py
new file mode 100644
index 0000000..a645e95
--- /dev/null
+++ b/backend/app/models/vote.py
@@ -0,0 +1,53 @@
+from sqlalchemy import Column, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+
+from app.database import Base
+
+
+class BillVote(Base):
+ __tablename__ = "bill_votes"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False)
+ congress = Column(Integer, nullable=False)
+ chamber = Column(String(50), nullable=False)
+ session = Column(Integer, nullable=False)
+ roll_number = Column(Integer, nullable=False)
+ question = Column(Text)
+ description = Column(Text)
+ vote_date = Column(Date)
+ yeas = Column(Integer)
+ nays = Column(Integer)
+ not_voting = Column(Integer)
+ result = Column(String(200))
+ source_url = Column(String)
+ fetched_at = Column(DateTime(timezone=True))
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ positions = relationship("MemberVotePosition", back_populates="vote", cascade="all, delete-orphan")
+
+ __table_args__ = (
+ Index("ix_bill_votes_bill_id", "bill_id"),
+ UniqueConstraint("congress", "chamber", "session", "roll_number", name="uq_bill_votes_roll"),
+ )
+
+
+class MemberVotePosition(Base):
+ __tablename__ = "member_vote_positions"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ vote_id = Column(Integer, ForeignKey("bill_votes.id", ondelete="CASCADE"), nullable=False)
+ bioguide_id = Column(String, ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True)
+ member_name = Column(String(200))
+ party = Column(String(50))
+ state = Column(String(10))
+ position = Column(String(50), nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ vote = relationship("BillVote", back_populates="positions")
+
+ __table_args__ = (
+ Index("ix_member_vote_positions_vote_id", "vote_id"),
+ Index("ix_member_vote_positions_bioguide_id", "bioguide_id"),
+ )
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 89cf270..25ab10b 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -337,3 +337,34 @@ class CollectionDetailSchema(CollectionSchema):
class BriefShareResponse(BaseModel):
brief: BriefSchema
bill: BillSchema
+
+
+# ── Votes ──────────────────────────────────────────────────────────────────────
+
+class MemberVotePositionSchema(BaseModel):
+ bioguide_id: Optional[str] = None
+ member_name: Optional[str] = None
+ party: Optional[str] = None
+ state: Optional[str] = None
+ position: str
+
+ model_config = {"from_attributes": True}
+
+
+class BillVoteSchema(BaseModel):
+ id: int
+ congress: int
+ chamber: str
+ session: int
+ roll_number: int
+ question: Optional[str] = None
+ description: Optional[str] = None
+ vote_date: Optional[date] = None
+ yeas: Optional[int] = None
+ nays: Optional[int] = None
+ not_voting: Optional[int] = None
+ result: Optional[str] = None
+ source_url: Optional[str] = None
+ positions: list[MemberVotePositionSchema] = []
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/services/congress_api.py b/backend/app/services/congress_api.py
index 774444f..4ffd538 100644
--- a/backend/app/services/congress_api.py
+++ b/backend/app/services/congress_api.py
@@ -90,6 +90,11 @@ def get_bill_text_versions(congress: int, bill_type: str, bill_number: int) -> d
return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/text", {})
+def get_vote_detail(congress: int, chamber: str, session: int, roll_number: int) -> dict:
+ chamber_slug = "house" if chamber.lower() == "house" else "senate"
+ return _get(f"/vote/{congress}/{chamber_slug}/{session}/{roll_number}", {})
+
+
def get_members(offset: int = 0, limit: int = 250, current_member: bool = True) -> dict:
params: dict = {"offset": offset, "limit": limit}
if current_member:
diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py
index 99054b1..70209c2 100644
--- a/backend/app/workers/notification_dispatcher.py
+++ b/backend/app/workers/notification_dispatcher.py
@@ -66,7 +66,9 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
if not key:
key = "referral" if payload.get("milestone_tier") == "referral" else "vote"
- all_filters = prefs.get("alert_filters") or {}
+ all_filters = prefs.get("alert_filters")
+ if all_filters is None:
+ return True # user hasn't configured filters yet — send everything
mode_filters = all_filters.get(follow_mode) or {}
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
diff --git a/backend/app/workers/vote_fetcher.py b/backend/app/workers/vote_fetcher.py
new file mode 100644
index 0000000..c553359
--- /dev/null
+++ b/backend/app/workers/vote_fetcher.py
@@ -0,0 +1,239 @@
+"""
+Vote fetcher — fetches roll-call vote data for bills.
+
+Roll-call votes are referenced in bill actions as recordedVotes objects.
+Each recordedVote contains a direct URL to the source XML:
+ - House: https://clerk.house.gov/evs/{year}/roll{NNN}.xml
+ - Senate: https://www.senate.gov/legislative/LIS/roll_call_votes/...
+
+We fetch and parse that XML directly rather than going through a
+Congress.gov API endpoint (which doesn't expose vote detail).
+
+Triggered on-demand from GET /api/bills/{bill_id}/votes when no votes
+are stored yet.
+"""
+import logging
+import xml.etree.ElementTree as ET
+from datetime import date, datetime, timezone
+
+import requests
+
+from app.database import get_sync_db
+from app.models.bill import Bill
+from app.models.member import Member
+from app.models.vote import BillVote, MemberVotePosition
+from app.services.congress_api import get_bill_actions as _api_get_bill_actions
+from app.workers.celery_app import celery_app
+
+logger = logging.getLogger(__name__)
+
+_FETCH_TIMEOUT = 15
+
+
+def _parse_date(s) -> date | None:
+ if not s:
+ return None
+ try:
+ return date.fromisoformat(str(s)[:10])
+ except Exception:
+ return None
+
+
+def _fetch_xml(url: str) -> ET.Element:
+ resp = requests.get(url, timeout=_FETCH_TIMEOUT)
+ resp.raise_for_status()
+ return ET.fromstring(resp.content)
+
+
+def _parse_house_xml(root: ET.Element) -> dict:
+ """Parse House Clerk roll-call XML (clerk.house.gov/evs/...)."""
+ meta = root.find("vote-metadata")
+ question = (meta.findtext("vote-question") or "").strip() if meta is not None else ""
+ result = (meta.findtext("vote-result") or "").strip() if meta is not None else ""
+
+ totals = root.find(".//totals-by-vote")
+ yeas = int((totals.findtext("yea-total") or "0").strip()) if totals is not None else 0
+ nays = int((totals.findtext("nay-total") or "0").strip()) if totals is not None else 0
+ not_voting = int((totals.findtext("not-voting-total") or "0").strip()) if totals is not None else 0
+
+ members = []
+ for rv in root.findall(".//recorded-vote"):
+ leg = rv.find("legislator")
+ if leg is None:
+ continue
+ members.append({
+ "bioguide_id": leg.get("name-id"),
+ "member_name": (leg.text or "").strip(),
+ "party": leg.get("party"),
+ "state": leg.get("state"),
+ "position": (rv.findtext("vote") or "Not Voting").strip(),
+ })
+
+ return {"question": question, "result": result, "yeas": yeas, "nays": nays,
+ "not_voting": not_voting, "members": members}
+
+
+def _parse_senate_xml(root: ET.Element) -> dict:
+ """Parse Senate LIS roll-call XML (senate.gov/legislative/LIS/...)."""
+ question = (root.findtext("vote_question_text") or root.findtext("question") or "").strip()
+ result = (root.findtext("vote_result_text") or "").strip()
+
+ counts = root.find("vote_counts")
+ yeas = int((counts.findtext("yeas") or "0").strip()) if counts is not None else 0
+ nays = int((counts.findtext("nays") or "0").strip()) if counts is not None else 0
+ not_voting = int((counts.findtext("absent") or "0").strip()) if counts is not None else 0
+
+ members = []
+ for m in root.findall(".//member"):
+ first = (m.findtext("first_name") or "").strip()
+ last = (m.findtext("last_name") or "").strip()
+ members.append({
+ "bioguide_id": (m.findtext("bioguide_id") or "").strip() or None,
+ "member_name": f"{first} {last}".strip(),
+ "party": m.findtext("party"),
+ "state": m.findtext("state"),
+ "position": (m.findtext("vote_cast") or "Not Voting").strip(),
+ })
+
+ return {"question": question, "result": result, "yeas": yeas, "nays": nays,
+ "not_voting": not_voting, "members": members}
+
+
+def _parse_vote_xml(url: str, chamber: str) -> dict:
+ root = _fetch_xml(url)
+ if chamber.lower() == "house":
+ return _parse_house_xml(root)
+ return _parse_senate_xml(root)
+
+
+def _collect_recorded_votes(congress: int, bill_type: str, bill_number: int) -> list[dict]:
+ """Page through all bill actions and collect unique recordedVotes entries."""
+ seen: set[tuple] = set()
+ recorded: list[dict] = []
+ offset = 0
+
+ while True:
+ data = _api_get_bill_actions(congress, bill_type, bill_number, offset=offset)
+ actions = data.get("actions", [])
+ pagination = data.get("pagination", {})
+
+ for action in actions:
+ for rv in action.get("recordedVotes", []):
+ chamber = rv.get("chamber", "")
+ session = int(rv.get("sessionNumber") or rv.get("session") or 1)
+ roll_number = rv.get("rollNumber")
+ if not roll_number:
+ continue
+ roll_number = int(roll_number)
+ key = (chamber, session, roll_number)
+ if key not in seen:
+ seen.add(key)
+ recorded.append({
+ "chamber": chamber,
+ "session": session,
+ "roll_number": roll_number,
+ "date": action.get("actionDate"),
+ "url": rv.get("url"),
+ })
+
+ total = pagination.get("count", 0)
+ offset += len(actions)
+ if offset >= total or not actions:
+ break
+
+ return recorded
+
+
+@celery_app.task(bind=True, name="app.workers.vote_fetcher.fetch_bill_votes")
+def fetch_bill_votes(self, bill_id: str) -> dict:
+ """Fetch and store roll-call votes for a single bill."""
+ db = get_sync_db()
+ try:
+ bill = db.get(Bill, bill_id)
+ if not bill:
+ return {"error": f"Bill {bill_id} not found"}
+
+ recorded = _collect_recorded_votes(bill.congress_number, bill.bill_type, bill.bill_number)
+
+ if not recorded:
+ logger.info(f"fetch_bill_votes({bill_id}): no recorded votes in actions")
+ return {"bill_id": bill_id, "stored": 0, "skipped": 0}
+
+ now = datetime.now(timezone.utc)
+ stored = 0
+ skipped = 0
+
+ # Cache known bioguide IDs to avoid N+1 member lookups
+ known_bioguides: set[str] = {
+ row[0] for row in db.query(Member.bioguide_id).all()
+ }
+
+ for rv in recorded:
+ chamber = rv["chamber"]
+ session = rv["session"]
+ roll_number = rv["roll_number"]
+ source_url = rv.get("url")
+
+ existing = (
+ db.query(BillVote)
+ .filter_by(
+ congress=bill.congress_number,
+ chamber=chamber,
+ session=session,
+ roll_number=roll_number,
+ )
+ .first()
+ )
+ if existing:
+ skipped += 1
+ continue
+
+ if not source_url:
+ logger.warning(f"No URL for {chamber} roll {roll_number} — skipping")
+ continue
+
+ try:
+ parsed = _parse_vote_xml(source_url, chamber)
+ except Exception as exc:
+ logger.warning(f"Could not parse vote XML {source_url}: {exc}")
+ continue
+
+ bill_vote = BillVote(
+ bill_id=bill_id,
+ congress=bill.congress_number,
+ chamber=chamber,
+ session=session,
+ roll_number=roll_number,
+ question=parsed["question"],
+ description=None,
+ vote_date=_parse_date(rv.get("date")),
+ yeas=parsed["yeas"],
+ nays=parsed["nays"],
+ not_voting=parsed["not_voting"],
+ result=parsed["result"],
+ source_url=source_url,
+ fetched_at=now,
+ )
+ db.add(bill_vote)
+ db.flush()
+
+ for pos in parsed["members"]:
+ bioguide_id = pos.get("bioguide_id")
+ if bioguide_id and bioguide_id not in known_bioguides:
+ bioguide_id = None
+ db.add(MemberVotePosition(
+ vote_id=bill_vote.id,
+ bioguide_id=bioguide_id,
+ member_name=pos.get("member_name"),
+ party=pos.get("party"),
+ state=pos.get("state"),
+ position=pos.get("position") or "Not Voting",
+ ))
+
+ db.commit()
+ stored += 1
+
+ logger.info(f"fetch_bill_votes({bill_id}): {stored} stored, {skipped} skipped")
+ return {"bill_id": bill_id, "stored": stored, "skipped": skipped}
+ finally:
+ db.close()
diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx
index c9eec91..e5550a8 100644
--- a/frontend/app/bills/[id]/page.tsx
+++ b/frontend/app/bills/[id]/page.tsx
@@ -9,6 +9,7 @@ import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline";
+import { VotePanel } from "@/components/bills/VotePanel";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
@@ -170,6 +171,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text}
/>
+
{vote.question}
+ )} +