Files
PocketVeto/frontend/app/members/[id]/page.tsx
Jack Levy a66b5b4bcb feat(interest): add public interest tracking for members of Congress
Adds Google Trends, NewsAPI, and Google News RSS scoring for members,
mirroring the existing bill interest pipeline. Member profiles now show
a Public Interest chart (with signal breakdown) and a Related News panel.

Key changes:
- New member_trend_scores + member_news_articles tables (migration 0008)
- fetch_gnews_articles() added to news_service for unlimited RSS article storage
- Bill news fetcher now combines NewsAPI + Google News RSS (more coverage)
- New member_interest Celery worker with scheduled news + trend tasks
- GET /members/{id}/trend and /news API endpoints
- TrendChart redesigned with signal breakdown badges and bar+line combo chart
- NewsPanel accepts generic article shape (bills and members)

Co-Authored-By: Jack Levy
2026-03-01 00:36:30 -05:00

236 lines
9.9 KiB
TypeScript
Raw 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>
)}
{/* 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>
);
}