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:
322
frontend/lib/api.ts
Normal file
322
frontend/lib/api.ts
Normal 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),
|
||||
};
|
||||
46
frontend/lib/hooks/useBills.ts
Normal file
46
frontend/lib/hooks/useBills.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
13
frontend/lib/hooks/useDashboard.ts
Normal file
13
frontend/lib/hooks/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
50
frontend/lib/hooks/useFollows.ts
Normal file
50
frontend/lib/hooks/useFollows.ts
Normal 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"] }),
|
||||
});
|
||||
}
|
||||
46
frontend/lib/hooks/useMembers.ts
Normal file
46
frontend/lib/hooks/useMembers.ts
Normal 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
28
frontend/lib/topics.ts
Normal 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
271
frontend/lib/types.ts
Normal 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
67
frontend/lib/utils.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user