feat: PocketVeto v1.0.0 — initial public release

Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:35:01 -04:00
commit 4c86a5b9ca
150 changed files with 19859 additions and 0 deletions

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

@@ -0,0 +1,322 @@
import axios from "axios";
import type {
AlignmentData,
Bill,
BillAction,
BillDetail,
BillVote,
BriefSchema,
Collection,
CollectionDetail,
DashboardData,
Follow,
Member,
MemberTrendScore,
MemberNewsArticle,
NewsArticle,
NotificationEvent,
NotificationSettings,
NotificationSettingsUpdate,
PaginatedResponse,
SettingsData,
TrendScore,
} from "./types";
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "",
timeout: 30000,
});
// Attach JWT from localStorage on every request
apiClient.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("pocketveto-auth");
if (stored) {
const { state } = JSON.parse(stored);
if (state?.token) {
config.headers.Authorization = `Bearer ${state.token}`;
}
}
} catch {
// ignore parse errors
}
}
return config;
});
interface AuthUser {
id: number;
email: string;
is_admin: boolean;
notification_prefs: Record<string, unknown>;
created_at: string;
}
interface TokenResponse {
access_token: string;
token_type: string;
user: AuthUser;
}
// Auth
export const authAPI = {
register: (email: string, password: string) =>
apiClient.post<TokenResponse>("/api/auth/register", { email, password }).then((r) => r.data),
login: (email: string, password: string) =>
apiClient.post<TokenResponse>("/api/auth/login", { email, password }).then((r) => r.data),
me: () =>
apiClient.get<AuthUser>("/api/auth/me").then((r) => r.data),
};
// 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),
getVotes: (id: string) =>
apiClient.get<BillVote[]>(`/api/bills/${id}/votes`).then((r) => r.data),
generateDraft: (id: string, body: {
stance: string;
recipient: string;
tone: string;
selected_points: string[];
include_citations: boolean;
zip_code?: string;
rep_name?: string;
}) =>
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
// Collections
export const collectionsAPI = {
list: () =>
apiClient.get<Collection[]>("/api/collections").then((r) => r.data),
create: (name: string, is_public: boolean) =>
apiClient.post<Collection>("/api/collections", { name, is_public }).then((r) => r.data),
get: (id: number) =>
apiClient.get<CollectionDetail>(`/api/collections/${id}`).then((r) => r.data),
update: (id: number, data: { name?: string; is_public?: boolean }) =>
apiClient.patch<Collection>(`/api/collections/${id}`, data).then((r) => r.data),
delete: (id: number) => apiClient.delete(`/api/collections/${id}`),
addBill: (id: number, bill_id: string) =>
apiClient.post(`/api/collections/${id}/bills/${bill_id}`).then((r) => r.data),
removeBill: (id: number, bill_id: string) =>
apiClient.delete(`/api/collections/${id}/bills/${bill_id}`),
getByShareToken: (token: string) =>
apiClient.get<CollectionDetail>(`/api/collections/share/${token}`).then((r) => r.data),
};
// Share (public)
export const shareAPI = {
getBrief: (token: string) =>
apiClient.get<{ brief: BriefSchema; bill: Bill }>(`/api/share/brief/${token}`).then((r) => r.data),
getCollection: (token: string) =>
apiClient.get<CollectionDetail>(`/api/share/collection/${token}`).then((r) => r.data),
};
// Notes
export const notesAPI = {
get: (billId: string) =>
apiClient.get<import("./types").BillNote>(`/api/notes/${billId}`).then((r) => r.data),
upsert: (billId: string, content: string, pinned: boolean) =>
apiClient.put<import("./types").BillNote>(`/api/notes/${billId}`, { content, pinned }).then((r) => r.data),
delete: (billId: string) =>
apiClient.delete(`/api/notes/${billId}`),
};
// 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),
byZip: (zip: string) =>
apiClient.get<Member[]>(`/api/members/by-zip/${zip}`).then((r) => r.data),
getBills: (id: string, params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
getTrend: (id: string, days?: number) =>
apiClient.get<MemberTrendScore[]>(`/api/members/${id}/trend`, { params: { days } }).then((r) => r.data),
getNews: (id: string) =>
apiClient.get<MemberNewsArticle[]>(`/api/members/${id}/news`).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}`),
updateMode: (id: number, mode: string) =>
apiClient.patch<Follow>(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
};
// 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),
};
export interface LLMModel {
id: string;
name: string;
}
// 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),
listModels: (provider: string) =>
apiClient.get<{ models: LLMModel[]; error?: string }>("/api/settings/llm-models", { params: { provider } }).then((r) => r.data),
};
export interface AdminUser {
id: number;
email: string;
is_admin: boolean;
follow_count: number;
created_at: string;
}
export interface ApiHealthResult {
status: "ok" | "error" | "skipped";
detail: string;
latency_ms?: number;
}
export interface ApiHealth {
congress_gov: ApiHealthResult;
govinfo: ApiHealthResult;
newsapi: ApiHealthResult;
google_news: ApiHealthResult;
}
export interface AnalysisStats {
total_bills: number;
docs_fetched: number;
briefs_generated: number;
full_briefs: number;
amendment_briefs: number;
uncited_briefs: number;
no_text_bills: number;
pending_llm: number;
bills_missing_sponsor: number;
bills_missing_metadata: number;
bills_missing_actions: number;
unlabeled_briefs: number;
remaining: number;
}
export interface NotificationTestResult {
status: "ok" | "error";
detail: string;
event_count?: number;
}
// Notifications
export const notificationsAPI = {
getSettings: () =>
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
updateSettings: (data: NotificationSettingsUpdate) =>
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
resetRssToken: () =>
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
testNtfy: (data: {
ntfy_topic_url: string;
ntfy_auth_method: string;
ntfy_token: string;
ntfy_username: string;
ntfy_password: string;
}) =>
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () =>
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
testEmail: () =>
apiClient.post<NotificationTestResult>("/api/notifications/test/email").then((r) => r.data),
testFollowMode: (mode: string, event_type: string) =>
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
getHistory: () =>
apiClient.get<NotificationEvent[]>("/api/notifications/history").then((r) => r.data),
};
// Admin
export const adminAPI = {
// Stats
getStats: () =>
apiClient.get<AnalysisStats>("/api/admin/stats").then((r) => r.data),
// Users
listUsers: () =>
apiClient.get<AdminUser[]>("/api/admin/users").then((r) => r.data),
deleteUser: (id: number) =>
apiClient.delete(`/api/admin/users/${id}`),
toggleAdmin: (id: number) =>
apiClient.patch<AdminUser>(`/api/admin/users/${id}/toggle-admin`).then((r) => r.data),
// Tasks
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),
backfillSponsors: () =>
apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data),
backfillCitations: () =>
apiClient.post("/api/admin/backfill-citations").then((r) => r.data),
triggerFetchActions: () =>
apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data),
backfillAllActions: () =>
apiClient.post("/api/admin/backfill-all-actions").then((r) => r.data),
backfillMetadata: () =>
apiClient.post("/api/admin/backfill-metadata").then((r) => r.data),
backfillLabels: () =>
apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
resumeAnalysis: () =>
apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
triggerWeeklyDigest: () =>
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
getApiHealth: () =>
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
getTaskStatus: (taskId: string) =>
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
getNewsApiQuota: () =>
apiClient.get<{ remaining: number; limit: number }>("/api/admin/newsapi-quota").then((r) => r.data),
clearGnewsCache: () =>
apiClient.post<{ cleared: number }>("/api/admin/clear-gnews-cache").then((r) => r.data),
submitLlmBatch: () =>
apiClient.post("/api/admin/submit-llm-batch").then((r) => r.data),
getLlmBatchStatus: () =>
apiClient.get<{ status: string; batch_id?: string; doc_count?: number; submitted_at?: string }>(
"/api/admin/llm-batch-status"
).then((r) => r.data),
backfillCosponsors: () =>
apiClient.post("/api/admin/backfill-cosponsors").then((r) => r.data),
backfillCategories: () =>
apiClient.post("/api/admin/backfill-categories").then((r) => r.data),
calculateEffectiveness: () =>
apiClient.post("/api/admin/calculate-effectiveness").then((r) => r.data),
};
// Alignment
export const alignmentAPI = {
get: () =>
apiClient.get<AlignmentData>("/api/alignment").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: 0, // Always fetch fresh — news arrives async after brief generation
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,13 @@
import { useQuery } from "@tanstack/react-query";
import { dashboardAPI } from "../api";
import { useAuthStore } from "@/stores/authStore";
export function useDashboard() {
const token = useAuthStore((s) => s.token);
return useQuery({
queryKey: ["dashboard", !!token],
queryFn: () => dashboardAPI.get(),
staleTime: 2 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
});
}

View File

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

View File

@@ -0,0 +1,46 @@
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,
});
}
export function useMemberTrend(id: string, days = 30) {
return useQuery({
queryKey: ["member-trend", id, days],
queryFn: () => membersAPI.getTrend(id, days),
staleTime: 60 * 60 * 1000,
enabled: !!id,
});
}
export function useMemberNews(id: string) {
return useQuery({
queryKey: ["member-news", id],
queryFn: () => membersAPI.getNews(id),
staleTime: 10 * 60 * 1000,
enabled: !!id,
});
}

28
frontend/lib/topics.ts Normal file
View File

@@ -0,0 +1,28 @@
export const TOPICS = [
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
];
export const TOPIC_TAGS = new Set(TOPICS.map((t) => t.tag));
export const TOPIC_LABEL: Record<string, string> = Object.fromEntries(
TOPICS.map((t) => [t.tag, t.label])
);

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

@@ -0,0 +1,271 @@
export interface MemberTerm {
congress?: number;
chamber?: string;
partyName?: string;
stateCode?: string;
stateName?: string;
startYear?: number;
endYear?: number;
district?: number;
}
export interface MemberLeadership {
type?: string;
congress?: number;
current?: boolean;
}
export interface MemberTrendScore {
score_date: string;
newsapi_count: number;
gnews_count: number;
gtrends_score: number;
composite_score: number;
}
export interface MemberNewsArticle {
id: number;
source?: string;
headline?: string;
url?: string;
published_at?: string;
relevance_score?: number;
}
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;
official_url?: string;
congress_url?: string;
birth_year?: string;
address?: string;
phone?: string;
terms_json?: MemberTerm[];
leadership_json?: MemberLeadership[];
sponsored_count?: number;
cosponsored_count?: number;
effectiveness_score?: number;
effectiveness_percentile?: number;
effectiveness_tier?: string;
latest_trend?: MemberTrendScore;
}
export interface CitedPoint {
text: string;
citation: string;
quote: string;
label?: "cited_fact" | "inference";
}
export interface BriefSchema {
id: number;
brief_type?: string;
summary?: string;
key_points?: (string | CitedPoint)[];
risks?: (string | CitedPoint)[];
deadlines?: { date: string | null; description: string }[];
topic_tags?: string[];
llm_provider?: string;
llm_model?: string;
govinfo_url?: string;
share_token?: 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;
has_document?: boolean;
bill_category?: string;
}
export interface AlignmentScore {
bioguide_id: string;
name: string;
party?: string;
state?: string;
chamber?: string;
photo_url?: string;
effectiveness_percentile?: number;
aligned: number;
opposed: number;
total: number;
alignment_pct?: number;
}
export interface AlignmentData {
members: AlignmentScore[];
total_bills_with_stance: number;
total_bills_with_votes: number;
}
export interface BillDetail extends Bill {
actions: BillAction[];
news_articles: NewsArticle[];
trend_scores: TrendScore[];
briefs: BriefSchema[];
has_document: boolean;
}
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;
follow_mode: "neutral" | "pocket_veto" | "pocket_boost";
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;
api_keys_configured: Record<string, boolean>;
}
export interface BillNote {
id: number;
bill_id: string;
content: string;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationSettings {
ntfy_topic_url: string;
ntfy_auth_method: string; // "none" | "token" | "basic"
ntfy_token: string;
ntfy_username: string;
ntfy_password_set: boolean;
ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null;
email_enabled: boolean;
email_address: string;
digest_enabled: boolean;
digest_frequency: "daily" | "weekly";
quiet_hours_start: number | null;
quiet_hours_end: number | null;
timezone: string | null; // IANA name, e.g. "America/New_York"
alert_filters: Record<string, Record<string, boolean | string[]>> | null;
}
// Write-only — ntfy_password is accepted on PUT but never returned (use ntfy_password_set to check)
export interface NotificationSettingsUpdate extends Omit<Partial<NotificationSettings>, "ntfy_password_set"> {
ntfy_password?: string;
}
export interface Collection {
id: number;
name: string;
slug: string;
is_public: boolean;
share_token: string;
bill_count: number;
created_at: string;
}
export interface CollectionDetail extends Collection {
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 {
id: number;
bill_id: string;
event_type: "new_document" | "new_amendment" | "bill_updated";
payload: {
bill_title?: string;
bill_label?: string;
brief_summary?: string;
bill_url?: string;
} | null;
dispatched_at: string | null;
created_at: string;
}

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

@@ -0,0 +1,67 @@
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-600 text-white";
if (p.includes("republican") || p === "r") return "bg-red-600 text-white";
return "bg-slate-500 text-white";
}
export function congressLabel(congress: number): string {
const lastTwo = congress % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${congress}th Congress`;
const suffixes: Record<number, string> = { 1: "st", 2: "nd", 3: "rd" };
return `${congress}${suffixes[congress % 10] ?? "th"} Congress`;
}
export function chamberBadgeColor(chamber?: string): string {
if (!chamber) return "bg-muted text-muted-foreground";
const c = chamber.toLowerCase();
if (c === "senate") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400";
if (c.startsWith("house")) return "bg-slate-100 text-slate-600 dark:bg-slate-700/50 dark:text-slate-300";
return "bg-muted text-muted-foreground";
}
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";
}