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:
Jack Levy
2026-03-02 20:33:32 -05:00
parent 676bf1b78d
commit 91473e6464
13 changed files with 673 additions and 3 deletions

View File

@@ -56,6 +56,7 @@
- [x] Weekly digest — Monday 8:30 AM UTC; 7-day summary of followed bill activity via ntfy + RSS - [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] 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] 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 ### UX & Polish
- [x] Party badges — solid red/blue/slate, readable in light and dark mode - [x] Party badges — solid red/blue/slate, readable in light and dark mode
@@ -71,7 +72,7 @@
### Phase 4 — Accountability ### 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. - [ ] **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. - [ ] **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] Public browsing (no account required to read)
- [x] Multi-user auth with admin panel - [x] Multi-user auth with admin panel
- [ ] Member effectiveness score - [ ] Member effectiveness score
- [ ] Roll-call vote data - [x] Roll-call vote data
All other items above are post-v1.0. All other items above are post-v1.0.

View 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")

View File

@@ -12,6 +12,7 @@ from app.schemas.schemas import (
BillDetailSchema, BillDetailSchema,
BillSchema, BillSchema,
BillActionSchema, BillActionSchema,
BillVoteSchema,
NewsArticleSchema, NewsArticleSchema,
PaginatedResponse, PaginatedResponse,
TrendScoreSchema, 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() 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) @router.post("/{bill_id}/draft-letter", response_model=DraftLetterResponse)
async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSession = Depends(get_db)): async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSession = Depends(get_db)):
from app.models.setting import AppSetting from app.models.setting import AppSetting

View File

@@ -11,6 +11,7 @@ from app.models.setting import AppSetting
from app.models.trend import TrendScore from app.models.trend import TrendScore
from app.models.committee import Committee, CommitteeBill from app.models.committee import Committee, CommitteeBill
from app.models.user import User from app.models.user import User
from app.models.vote import BillVote, MemberVotePosition
__all__ = [ __all__ = [
"Bill", "Bill",
@@ -18,12 +19,14 @@ __all__ = [
"BillDocument", "BillDocument",
"BillBrief", "BillBrief",
"BillNote", "BillNote",
"BillVote",
"Collection", "Collection",
"CollectionBill", "CollectionBill",
"Follow", "Follow",
"Member", "Member",
"MemberTrendScore", "MemberTrendScore",
"MemberNewsArticle", "MemberNewsArticle",
"MemberVotePosition",
"NewsArticle", "NewsArticle",
"NotificationEvent", "NotificationEvent",
"AppSetting", "AppSetting",

View 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"),
)

View File

@@ -337,3 +337,34 @@ class CollectionDetailSchema(CollectionSchema):
class BriefShareResponse(BaseModel): class BriefShareResponse(BaseModel):
brief: BriefSchema brief: BriefSchema
bill: BillSchema 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}

View File

@@ -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", {}) 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: def get_members(offset: int = 0, limit: int = 250, current_member: bool = True) -> dict:
params: dict = {"offset": offset, "limit": limit} params: dict = {"offset": offset, "limit": limit}
if current_member: if current_member:

View File

@@ -66,7 +66,9 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
if not key: if not key:
key = "referral" if payload.get("milestone_tier") == "referral" else "vote" 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 {} mode_filters = all_filters.get(follow_mode) or {}
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True))) return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))

View 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()

View File

@@ -9,6 +9,7 @@ import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel"; import { NotesPanel } from "@/components/bills/NotesPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline"; import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { VotePanel } from "@/components/bills/VotePanel";
import { TrendChart } from "@/components/bills/TrendChart"; import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel"; import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton"; import { FollowButton } from "@/components/shared/FollowButton";
@@ -170,6 +171,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
latestActionDate={bill.latest_action_date} latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text} latestActionText={bill.latest_action_text}
/> />
<VotePanel billId={bill.bill_id} />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<TrendChart data={trendData} /> <TrendChart data={trendData} />

View 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>
);
}

View File

@@ -3,6 +3,7 @@ import type {
Bill, Bill,
BillAction, BillAction,
BillDetail, BillDetail,
BillVote,
BriefSchema, BriefSchema,
Collection, Collection,
CollectionDetail, CollectionDetail,
@@ -78,6 +79,8 @@ export const billsAPI = {
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data), apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
getTrend: (id: string, days?: number) => getTrend: (id: string, days?: number) =>
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data), 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: { generateDraft: (id: string, body: {
stance: string; stance: string;
recipient: string; recipient: string;

View File

@@ -199,6 +199,31 @@ export interface CollectionDetail extends Collection {
bills: Bill[]; 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 { export interface NotificationEvent {
id: number; id: number;
bill_id: string; bill_id: string;