Files
PocketVeto/frontend/components/bills/TrendChart.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

135 lines
4.5 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 { TrendingUp, Newspaper, Radio } from "lucide-react";
import {
ComposedChart,
Line,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Legend,
} from "recharts";
import { TrendScore, MemberTrendScore } from "@/lib/types";
type AnyTrendScore = TrendScore | MemberTrendScore;
interface TrendChartProps {
data?: AnyTrendScore[];
title?: string;
}
function ScoreBadge({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-0.5">
<div className="text-muted-foreground">{icon}</div>
<span className="text-xs font-semibold tabular-nums">{value}</span>
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
);
}
export function TrendChart({ data, title = "Public Interest" }: TrendChartProps) {
const chartData = data?.map((d) => ({
date: new Date(d.score_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }),
score: Math.round(d.composite_score),
newsapi: d.newsapi_count,
gnews: d.gnews_count,
gtrends: Math.round(d.gtrends_score),
})) ?? [];
const latest = data?.[data.length - 1];
return (
<div className="bg-card border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{title}
</h3>
{latest !== undefined && (
<span className="text-2xl font-bold tabular-nums">{Math.round(latest.composite_score)}</span>
)}
</div>
{/* Signal breakdown badges */}
{latest && (
<div className="flex justify-around border border-border rounded-md p-2 bg-muted/30">
<ScoreBadge
label="NewsAPI"
value={latest.newsapi_count}
icon={<Newspaper className="w-3 h-3" />}
/>
<div className="w-px bg-border" />
<ScoreBadge
label="Google News"
value={latest.gnews_count}
icon={<Radio className="w-3 h-3" />}
/>
<div className="w-px bg-border" />
<ScoreBadge
label="Trends"
value={`${Math.round(latest.gtrends_score)}/100`}
icon={<TrendingUp className="w-3 h-3" />}
/>
</div>
)}
{chartData.length === 0 ? (
<p className="text-xs text-muted-foreground italic text-center py-8">
Interest data not yet available. Check back after the nightly scoring run.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<ComposedChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
fontSize: "12px",
}}
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
score: "Composite",
newsapi: "NewsAPI articles",
gnews: "Google News articles",
gtrends: "Google Trends",
};
return [value, labels[name] ?? name];
}}
/>
<Bar dataKey="gnews" fill="hsl(var(--muted-foreground))" opacity={0.3} name="gnews" radius={[2, 2, 0, 0]} />
<Bar dataKey="newsapi" fill="hsl(var(--primary))" opacity={0.3} name="newsapi" radius={[2, 2, 0, 0]} />
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
name="score"
/>
</ComposedChart>
</ResponsiveContainer>
)}
<p className="text-[10px] text-muted-foreground">
Composite 0100 · NewsAPI articles (max 40 pts) + Google News volume (max 30 pts) + Google Trends score (max 30 pts)
</p>
</div>
);
}