feat: roll-call votes + granular alert filter fix (v0.9.5)
Roll-call votes:
- Migration 0017: bill_votes + member_vote_positions tables
- Fetch vote XML directly from House Clerk / Senate LIS URLs
embedded in bill actions recordedVotes objects
- GET /api/bills/{id}/votes triggers background fetch on first view
- VotePanel on bill detail: yea/nay bar, result badge, followed
member positions with Sen./Rep. title, party badge, and state
Alert filter fix:
- _should_dispatch returns True when alert_filters is None so users
who haven't saved filters still receive all notifications
Authored-By: Jack Levy
This commit is contained in:
@@ -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.
|
||||
|
||||
63
backend/alembic/versions/0017_add_bill_votes.py
Normal file
63
backend/alembic/versions/0017_add_bill_votes.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
backend/app/models/vote.py
Normal file
53
backend/app/models/vote.py
Normal file
@@ -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"),
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
239
backend/app/workers/vote_fetcher.py
Normal file
239
backend/app/workers/vote_fetcher.py
Normal file
@@ -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()
|
||||
@@ -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}
|
||||
/>
|
||||
<VotePanel billId={bill.bill_id} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<TrendChart data={trendData} />
|
||||
|
||||
216
frontend/components/bills/VotePanel.tsx
Normal file
216
frontend/components/bills/VotePanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListChecks, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { billsAPI, followsAPI } from "@/lib/api";
|
||||
import { cn, formatDate, partyBadgeColor } from "@/lib/utils";
|
||||
import type { BillVote, MemberVotePosition } from "@/lib/types";
|
||||
|
||||
interface VotePanelProps {
|
||||
billId: string;
|
||||
}
|
||||
|
||||
export function VotePanel({ billId }: VotePanelProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const { data: votes, isLoading } = useQuery({
|
||||
queryKey: ["votes", billId],
|
||||
queryFn: () => billsAPI.getVotes(billId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: follows } = useQuery({
|
||||
queryKey: ["follows"],
|
||||
queryFn: () => followsAPI.list(),
|
||||
retry: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
const followedMemberIds = new Set(
|
||||
(follows || [])
|
||||
.filter((f) => f.follow_type === "member")
|
||||
.map((f) => f.follow_value)
|
||||
);
|
||||
|
||||
if (isLoading || !votes || votes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
Roll-Call Votes{" "}
|
||||
<span className="text-muted-foreground font-normal">({votes.length})</span>
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="divide-y divide-border">
|
||||
{votes.map((vote) => (
|
||||
<VoteRow key={vote.id} vote={vote} followedMemberIds={followedMemberIds} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VoteRow({
|
||||
vote,
|
||||
followedMemberIds,
|
||||
}: {
|
||||
vote: BillVote;
|
||||
followedMemberIds: Set<string>;
|
||||
}) {
|
||||
const [showPositions, setShowPositions] = useState(false);
|
||||
|
||||
const total = (vote.yeas ?? 0) + (vote.nays ?? 0) + (vote.not_voting ?? 0);
|
||||
const yeaPct = total > 0 ? ((vote.yeas ?? 0) / total) * 100 : 0;
|
||||
const nayPct = total > 0 ? ((vote.nays ?? 0) / total) * 100 : 0;
|
||||
|
||||
const resultLower = (vote.result ?? "").toLowerCase();
|
||||
const passed =
|
||||
resultLower.includes("pass") ||
|
||||
resultLower.includes("agreed") ||
|
||||
resultLower.includes("adopted") ||
|
||||
resultLower.includes("enacted");
|
||||
|
||||
const followedPositions: MemberVotePosition[] = vote.positions.filter(
|
||||
(p) => p.bioguide_id && followedMemberIds.has(p.bioguide_id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{vote.result && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded font-medium shrink-0",
|
||||
passed
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{vote.result}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{vote.chamber} Roll #{vote.roll_number}
|
||||
{vote.vote_date && ` · ${formatDate(vote.vote_date)}`}
|
||||
</span>
|
||||
</div>
|
||||
{vote.question && (
|
||||
<p className="text-sm font-medium">{vote.question}</p>
|
||||
)}
|
||||
</div>
|
||||
{vote.source_url && (
|
||||
<a
|
||||
href={vote.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Source ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yea / Nay bar */}
|
||||
{total > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-2 rounded overflow-hidden bg-muted gap-0.5">
|
||||
<div
|
||||
className="bg-emerald-500 transition-all"
|
||||
style={{ width: `${yeaPct}%` }}
|
||||
title={`Yea: ${vote.yeas}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${nayPct}%` }}
|
||||
title={`Nay: ${vote.nays}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
{vote.yeas ?? "—"} Yea
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
{vote.nays ?? "—"} Nay
|
||||
</span>
|
||||
{(vote.not_voting ?? 0) > 0 && (
|
||||
<span className="text-muted-foreground">{vote.not_voting} Not Voting</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Followed member positions */}
|
||||
{followedPositions.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowPositions((e) => !e)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showPositions ? "Hide" : "Show"} {followedPositions.length} followed member
|
||||
{followedPositions.length !== 1 ? "s'" : "'"} vote
|
||||
{followedPositions.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showPositions && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{followedPositions.map((p, i) => (
|
||||
<div key={p.bioguide_id ?? i} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
p.position === "Yea"
|
||||
? "bg-emerald-500"
|
||||
: p.position === "Nay"
|
||||
? "bg-red-500"
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{vote.chamber === "Senate" ? "Sen." : "Rep."}
|
||||
</span>
|
||||
<span className="font-medium">{p.member_name}</span>
|
||||
{p.party && (
|
||||
<span className={cn("px-1 py-0.5 rounded font-medium shrink-0", partyBadgeColor(p.party))}>
|
||||
{p.party}
|
||||
</span>
|
||||
)}
|
||||
{p.state && <span className="text-muted-foreground">{p.state}</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto font-medium shrink-0",
|
||||
p.position === "Yea"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: p.position === "Nay"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{p.position}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Bill,
|
||||
BillAction,
|
||||
BillDetail,
|
||||
BillVote,
|
||||
BriefSchema,
|
||||
Collection,
|
||||
CollectionDetail,
|
||||
@@ -78,6 +79,8 @@ export const billsAPI = {
|
||||
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getVotes: (id: string) =>
|
||||
apiClient.get<BillVote[]>(`/api/bills/${id}/votes`).then((r) => r.data),
|
||||
generateDraft: (id: string, body: {
|
||||
stance: string;
|
||||
recipient: string;
|
||||
|
||||
@@ -199,6 +199,31 @@ export interface CollectionDetail extends Collection {
|
||||
bills: Bill[];
|
||||
}
|
||||
|
||||
export interface MemberVotePosition {
|
||||
bioguide_id?: string;
|
||||
member_name?: string;
|
||||
party?: string;
|
||||
state?: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface BillVote {
|
||||
id: number;
|
||||
congress: number;
|
||||
chamber: string;
|
||||
session: number;
|
||||
roll_number: number;
|
||||
question?: string;
|
||||
description?: string;
|
||||
vote_date?: string;
|
||||
yeas?: number;
|
||||
nays?: number;
|
||||
not_voting?: number;
|
||||
result?: string;
|
||||
source_url?: string;
|
||||
positions: MemberVotePosition[];
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
id: number;
|
||||
bill_id: string;
|
||||
|
||||
Reference in New Issue
Block a user