Initial commit

This commit is contained in:
Jack Levy
2026-02-28 21:08:19 -05:00
commit e418dd9ae0
85 changed files with 5261 additions and 0 deletions

86
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,86 @@
import axios from "axios";
import type {
Bill,
BillAction,
BillDetail,
DashboardData,
Follow,
Member,
NewsArticle,
PaginatedResponse,
SettingsData,
TrendScore,
} from "./types";
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "",
timeout: 30000,
});
// Bills
export const billsAPI = {
list: (params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>("/api/bills", { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<BillDetail>(`/api/bills/${id}`).then((r) => r.data),
getActions: (id: string) =>
apiClient.get<BillAction[]>(`/api/bills/${id}/actions`).then((r) => r.data),
getNews: (id: string) =>
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),
};
// Members
export const membersAPI = {
list: (params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data),
getBills: (id: string, params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
};
// Follows
export const followsAPI = {
list: () =>
apiClient.get<Follow[]>("/api/follows").then((r) => r.data),
add: (follow_type: string, follow_value: string) =>
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
remove: (id: number) =>
apiClient.delete(`/api/follows/${id}`),
};
// Dashboard
export const dashboardAPI = {
get: () =>
apiClient.get<DashboardData>("/api/dashboard").then((r) => r.data),
};
// Search
export const searchAPI = {
search: (q: string) =>
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
};
// Settings
export const settingsAPI = {
get: () =>
apiClient.get<SettingsData>("/api/settings").then((r) => r.data),
update: (key: string, value: string) =>
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
testLLM: () =>
apiClient.post("/api/settings/test-llm").then((r) => r.data),
};
// Admin
export const adminAPI = {
triggerPoll: () =>
apiClient.post("/api/admin/trigger-poll").then((r) => r.data),
triggerMemberSync: () =>
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
triggerTrendScores: () =>
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
getTaskStatus: (taskId: string) =>
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
};

View File

@@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { billsAPI } from "../api";
export function useBills(params?: Record<string, unknown>) {
return useQuery({
queryKey: ["bills", params],
queryFn: () => billsAPI.list(params),
staleTime: 5 * 60 * 1000,
});
}
export function useBill(id: string) {
return useQuery({
queryKey: ["bill", id],
queryFn: () => billsAPI.get(id),
staleTime: 2 * 60 * 1000,
enabled: !!id,
});
}
export function useBillActions(id: string) {
return useQuery({
queryKey: ["bill-actions", id],
queryFn: () => billsAPI.getActions(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
});
}
export function useBillNews(id: string) {
return useQuery({
queryKey: ["bill-news", id],
queryFn: () => billsAPI.getNews(id),
staleTime: 10 * 60 * 1000,
enabled: !!id,
});
}
export function useBillTrend(id: string, days = 30) {
return useQuery({
queryKey: ["bill-trend", id, days],
queryFn: () => billsAPI.getTrend(id, days),
staleTime: 60 * 60 * 1000,
enabled: !!id,
});
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { dashboardAPI } from "../api";
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: () => dashboardAPI.get(),
staleTime: 2 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { followsAPI } from "../api";
export function useFollows() {
return useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
staleTime: 30 * 1000,
});
}
export function useAddFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ type, value }: { type: string; value: string }) =>
followsAPI.add(type, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
});
}
export function useRemoveFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => followsAPI.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
});
}
export function useIsFollowing(type: string, value: string) {
const { data: follows = [] } = useFollows();
return follows.find((f) => f.follow_type === type && f.follow_value === value);
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { membersAPI } from "../api";
export function useMembers(params?: Record<string, unknown>) {
return useQuery({
queryKey: ["members", params],
queryFn: () => membersAPI.list(params),
staleTime: 10 * 60 * 1000,
});
}
export function useMember(id: string) {
return useQuery({
queryKey: ["member", id],
queryFn: () => membersAPI.get(id),
staleTime: 10 * 60 * 1000,
enabled: !!id,
});
}
export function useMemberBills(id: string) {
return useQuery({
queryKey: ["member-bills", id],
queryFn: () => membersAPI.getBills(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
});
}

103
frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,103 @@
export interface Member {
bioguide_id: string;
name: string;
first_name?: string;
last_name?: string;
party?: string;
state?: string;
chamber?: string;
district?: string;
photo_url?: string;
}
export interface BriefSchema {
id: number;
summary?: string;
key_points?: string[];
risks?: string[];
deadlines?: { date: string | null; description: string }[];
topic_tags?: string[];
llm_provider?: string;
llm_model?: string;
created_at?: string;
}
export interface TrendScore {
score_date: string;
newsapi_count: number;
gnews_count: number;
gtrends_score: number;
composite_score: number;
}
export interface BillAction {
id: number;
action_date?: string;
action_text?: string;
action_type?: string;
chamber?: string;
}
export interface NewsArticle {
id: number;
source?: string;
headline?: string;
url?: string;
published_at?: string;
relevance_score?: number;
}
export interface Bill {
bill_id: string;
congress_number: number;
bill_type: string;
bill_number: number;
title?: string;
short_title?: string;
introduced_date?: string;
latest_action_date?: string;
latest_action_text?: string;
status?: string;
chamber?: string;
congress_url?: string;
sponsor?: Member;
latest_brief?: BriefSchema;
latest_trend?: TrendScore;
updated_at?: string;
}
export interface BillDetail extends Bill {
actions: BillAction[];
news_articles: NewsArticle[];
trend_scores: TrendScore[];
briefs: BriefSchema[];
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface Follow {
id: number;
follow_type: "bill" | "member" | "topic";
follow_value: string;
created_at: string;
}
export interface DashboardData {
feed: Bill[];
trending: Bill[];
follows: { bills: number; members: number; topics: number };
}
export interface SettingsData {
llm_provider: string;
llm_model: string;
congress_poll_interval_minutes: number;
newsapi_enabled: boolean;
pytrends_enabled: boolean;
}

52
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,52 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date?: string | null): string {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function billLabel(billType: string, billNumber: number): string {
const labels: Record<string, string> = {
hr: "H.R.",
s: "S.",
hjres: "H.J.Res.",
sjres: "S.J.Res.",
hconres: "H.Con.Res.",
sconres: "S.Con.Res.",
hres: "H.Res.",
sres: "S.Res.",
};
return `${labels[billType?.toLowerCase()] ?? billType?.toUpperCase()} ${billNumber}`;
}
export function partyColor(party?: string): string {
if (!party) return "text-muted-foreground";
const p = party.toLowerCase();
if (p.includes("democrat") || p === "d") return "text-blue-500";
if (p.includes("republican") || p === "r") return "text-red-500";
return "text-yellow-500";
}
export function partyBadgeColor(party?: string): string {
if (!party) return "bg-muted text-muted-foreground";
const p = party.toLowerCase();
if (p.includes("democrat") || p === "d") return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
if (p.includes("republican") || p === "r") return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
}
export function trendColor(score?: number): string {
if (!score) return "text-muted-foreground";
if (score >= 70) return "text-red-500";
if (score >= 40) return "text-yellow-500";
return "text-green-500";
}