Files
Jack Levy d0da0b8dce feat: Member Effectiveness Score + Representation Alignment View (v0.9.9)
Member Effectiveness Score
- New BillCosponsor table (migration 0018) with per-bill co-sponsor
  party data required for the bipartisan multiplier
- bill_category column on Bill (substantive | commemorative | administrative)
  set by a cheap one-shot LLM call after each brief is generated
- effectiveness_score / percentile / tier columns on Member
- New bill_classifier.py worker with 5 tasks:
    classify_bill_category  — triggered from llm_processor after brief
    fetch_bill_cosponsors   — triggered from congress_poller on new bill
    calculate_effectiveness_scores — nightly at 5 AM UTC
    backfill_bill_categories / backfill_all_bill_cosponsors — one-time
- Scoring: distance-traveled pts × bipartisan (1.5×) × substance (0.1×
  for commemorative) × leadership (1.2× for committee chairs)
- Percentile normalised within (seniority tier × party) buckets
- Effectiveness card on member detail page with colour-coded bar
- Admin panel: 3 new backfill/calculate controls in Maintenance section

Representation Alignment View
- New GET /api/alignment endpoint: cross-references user's stanced bill
  follows (pocket_veto/pocket_boost) with followed members' vote positions
- Efficient bulk queries — no N+1 loops
- New /alignment page with ranked member list and alignment bars
- Alignment added to sidebar nav (auth-required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 18:05:09 -04:00

272 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { use } from "react";
import Link from "next/link";
import Image from "next/image";
import {
ArrowLeft,
ExternalLink,
MapPin,
Phone,
Globe,
Star,
FileText,
Users,
} from "lucide-react";
import { useMember, useMemberBills, useMemberTrend, useMemberNews } from "@/lib/hooks/useMembers";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { BillCard } from "@/components/shared/BillCard";
import { cn, partyBadgeColor } from "@/lib/utils";
function ordinal(n: number) {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { data: member, isLoading } = useMember(id);
const { data: billsData } = useMemberBills(id);
const { data: trendData } = useMemberTrend(id, 30);
const { data: newsData } = useMemberNews(id);
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!member) return <div className="text-center py-20 text-muted-foreground">Member not found.</div>;
const currentLeadership = member.leadership_json?.filter((l) => l.current);
const termsSorted = [...(member.terms_json || [])].reverse();
return (
<div className="space-y-6">
{/* Back */}
<Link href="/members" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
Members
</Link>
{/* Bio header */}
<div className="bg-card border border-border rounded-lg p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-5">
{member.photo_url ? (
<Image
src={member.photo_url}
alt={member.name}
width={96}
height={96}
className="rounded-lg object-cover shrink-0 border border-border"
/>
) : (
<div className="w-24 h-24 rounded-lg bg-muted flex items-center justify-center shrink-0 border border-border">
<Users className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold">{member.name}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{member.party && (
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
{member.birth_year && (
<span className="text-sm text-muted-foreground">b. {member.birth_year}</span>
)}
</div>
</div>
{/* Leadership */}
{currentLeadership && currentLeadership.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{currentLeadership.map((l, i) => (
<span key={i} className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 bg-primary/10 text-primary rounded-full">
<Star className="w-3 h-3" />
{l.type}
</span>
))}
</div>
)}
{/* Contact */}
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground">
{member.address && (
<div className="flex items-start gap-1.5">
<MapPin className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>{member.address}</span>
</div>
)}
{member.phone && (
<div className="flex items-center gap-1.5">
<Phone className="w-3.5 h-3.5 shrink-0" />
<a href={`tel:${member.phone}`} className="hover:text-foreground transition-colors">
{member.phone}
</a>
</div>
)}
{member.official_url && (
<div className="flex items-center gap-1.5">
<Globe className="w-3.5 h-3.5 shrink-0" />
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors truncate max-w-xs">
{member.official_url.replace(/^https?:\/\//, "")}
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</div>
)}
{member.congress_url && (
<div className="flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 shrink-0" />
<a href={member.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
congress.gov profile
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</div>
)}
</div>
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column */}
<div className="md:col-span-2 space-y-6">
{/* Sponsored bills */}
<div>
<h2 className="font-semibold mb-4 flex items-center gap-2">
<FileText className="w-4 h-4" />
Sponsored Bills
{billsData?.total != null && (
<span className="text-xs text-muted-foreground font-normal">({billsData.total})</span>
)}
</h2>
{!billsData?.items?.length ? (
<p className="text-sm text-muted-foreground">No bills found.</p>
) : (
<div className="space-y-3">
{billsData.items.map((bill) => <BillCard key={bill.bill_id} bill={bill} />)}
</div>
)}
</div>
</div>
{/* Right column */}
<div className="space-y-4">
{/* Public Interest */}
<TrendChart data={trendData ?? []} title="Public Interest" />
{/* News */}
<NewsPanel articles={newsData} />
{/* Legislation stats */}
{(member.sponsored_count != null || member.cosponsored_count != null) && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Legislation</h3>
<div className="space-y-2">
{member.sponsored_count != null && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Sponsored</span>
<span className="font-medium">{member.sponsored_count.toLocaleString()}</span>
</div>
)}
{member.cosponsored_count != null && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Cosponsored</span>
<span className="font-medium">{member.cosponsored_count.toLocaleString()}</span>
</div>
)}
</div>
</div>
)}
{/* Effectiveness Score */}
{member.effectiveness_score != null && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Effectiveness Score</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Score</span>
<span className="font-medium">{member.effectiveness_score.toFixed(1)}</span>
</div>
{member.effectiveness_percentile != null && (
<>
<div className="h-1.5 bg-muted rounded overflow-hidden">
<div
className={`h-full rounded transition-all ${
member.effectiveness_percentile >= 66
? "bg-emerald-500"
: member.effectiveness_percentile >= 33
? "bg-amber-500"
: "bg-red-500"
}`}
style={{ width: `${member.effectiveness_percentile}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{Math.round(member.effectiveness_percentile)}th percentile
{member.effectiveness_tier ? ` among ${member.effectiveness_tier} members` : ""}
</p>
</>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
Measures legislative output: how far sponsored bills travel, bipartisan support, substance, and committee leadership.
</p>
</div>
</div>
)}
{/* Service history */}
{termsSorted.length > 0 && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Service History</h3>
<div className="space-y-2">
{termsSorted.map((term, i) => (
<div key={i} className="text-sm border-l-2 border-border pl-3">
<div className="font-medium">
{term.congress ? `${ordinal(term.congress)} Congress` : ""}
{term.startYear && term.endYear
? ` (${term.startYear}${term.endYear})`
: term.startYear
? ` (${term.startYear}present)`
: ""}
</div>
<div className="text-xs text-muted-foreground">
{[term.chamber, term.partyName, term.stateName].filter(Boolean).join(" · ")}
{term.district ? ` · District ${term.district}` : ""}
</div>
</div>
))}
</div>
</div>
)}
{/* All leadership roles */}
{member.leadership_json && member.leadership_json.length > 0 && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Leadership Roles</h3>
<div className="space-y-2">
{member.leadership_json.map((l, i) => (
<div key={i} className="flex items-start justify-between gap-2 text-sm">
<span className={l.current ? "font-medium" : "text-muted-foreground"}>{l.type}</span>
<span className="text-xs text-muted-foreground shrink-0">
{l.congress ? `${ordinal(l.congress)}` : ""}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}