diff --git a/.env.example b/.env.example index 786ac49..89b1187 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,12 @@ GEMINI_MODEL=gemini-1.5-pro OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_MODEL=llama3.1 +# ─── Google Civic Information API ───────────────────────────────────────────── +# Used for zip code → representative lookup in the Draft Letter panel. +# Free tier: 25,000 req/day. Enable the API at: +# https://console.cloud.google.com/apis/library/civicinfo.googleapis.com +CIVIC_API_KEY= + # ─── News ───────────────────────────────────────────────────────────────────── # Free key (100 req/day): https://newsapi.org/register NEWSAPI_KEY= diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 7e7743f..3a5dadd 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -272,9 +272,10 @@ async def api_health(current_user: User = Depends(get_current_admin)): asyncio.to_thread(_test_govinfo), asyncio.to_thread(_test_newsapi), asyncio.to_thread(_test_gnews), + asyncio.to_thread(_test_rep_lookup), return_exceptions=True, ) - keys = ["congress_gov", "govinfo", "newsapi", "google_news"] + keys = ["congress_gov", "govinfo", "newsapi", "google_news", "rep_lookup"] return { k: r if isinstance(r, dict) else {"status": "error", "detail": str(r)} for k, r in zip(keys, results) @@ -370,6 +371,54 @@ def _test_gnews() -> dict: return {"status": "error", "detail": str(exc)} +def _test_rep_lookup() -> dict: + import re as _re + import requests as req + def _call(): + # Step 1: Nominatim ZIP → lat/lng + r1 = req.get( + "https://nominatim.openstreetmap.org/search", + params={"postalcode": "20001", "country": "US", "format": "json", "limit": "1"}, + headers={"User-Agent": "PocketVeto/1.0"}, + timeout=10, + ) + r1.raise_for_status() + places = r1.json() + if not places: + return {"status": "error", "detail": "Nominatim: no result for test ZIP 20001"} + lat, lng = places[0]["lat"], places[0]["lon"] + half = 0.5 + # Step 2: TIGERweb identify → congressional district + r2 = req.get( + "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Legislative/MapServer/identify", + params={ + "f": "json", + "geometry": f"{lng},{lat}", + "geometryType": "esriGeometryPoint", + "sr": "4326", + "layers": "all", + "tolerance": "2", + "mapExtent": f"{float(lng)-half},{float(lat)-half},{float(lng)+half},{float(lat)+half}", + "imageDisplay": "100,100,96", + }, + timeout=15, + ) + r2.raise_for_status() + results = r2.json().get("results", []) + for item in results: + attrs = item.get("attributes", {}) + cd_field = next((k for k in attrs if _re.match(r"CD\d+FP$", k)), None) + if cd_field: + district = str(int(str(attrs[cd_field]))) if str(attrs[cd_field]).strip("0") else "At-large" + return {"status": "ok", "detail": f"Nominatim + TIGERweb reachable — district {district} found for ZIP 20001"} + layers = [r.get("layerName") for r in results] + return {"status": "error", "detail": f"Reachable but no CD field found. Layers: {layers}"} + try: + return _timed(_call) + except Exception as exc: + return {"status": "error", "detail": str(exc)} + + @router.get("/task-status/{task_id}") async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)): from app.workers.celery_app import celery_app diff --git a/backend/app/api/bills.py b/backend/app/api/bills.py index cf4109e..756a97f 100644 --- a/backend/app/api/bills.py +++ b/backend/app/api/bills.py @@ -35,7 +35,8 @@ class DraftLetterRequest(BaseModel): tone: Literal["short", "polite", "firm"] selected_points: list[str] include_citations: bool = True - zip_code: str | None = None # not stored, not logged + zip_code: str | None = None # not stored, not logged + rep_name: str | None = None # not stored, not logged class DraftLetterResponse(BaseModel): @@ -50,6 +51,7 @@ async def list_bills( topic: Optional[str] = Query(None), sponsor_id: Optional[str] = Query(None), q: Optional[str] = Query(None), + has_document: Optional[bool] = Query(None), page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), sort: str = Query("latest_action_date"), @@ -80,6 +82,12 @@ async def list_bills( Bill.short_title.ilike(f"%{q}%"), ) ) + if has_document is True: + doc_subq = select(BillDocument.bill_id).where(BillDocument.bill_id == Bill.bill_id).exists() + query = query.where(doc_subq) + elif has_document is False: + doc_subq = select(BillDocument.bill_id).where(BillDocument.bill_id == Bill.bill_id).exists() + query = query.where(~doc_subq) # Count total count_query = select(func.count()).select_from(query.subquery()) @@ -224,6 +232,7 @@ async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSessi selected_points=body.selected_points, include_citations=body.include_citations, zip_code=body.zip_code, + rep_name=body.rep_name, llm_provider=llm_provider_override, llm_model=llm_model_override, ) diff --git a/backend/app/api/members.py b/backend/app/api/members.py index ca75aa8..1b965a8 100644 --- a/backend/app/api/members.py +++ b/backend/app/api/members.py @@ -1,7 +1,23 @@ import logging +import re from datetime import datetime, timezone from typing import Optional +_FIPS_TO_STATE = { + "01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA", + "08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL", + "13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN", + "19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME", + "24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS", + "29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH", + "34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND", + "39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI", + "45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT", + "50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI", + "56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR", "78": "VI", +} + +import httpx from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +36,139 @@ logger = logging.getLogger(__name__) router = APIRouter() +@router.get("/by-zip/{zip_code}", response_model=list[MemberSchema]) +async def get_members_by_zip(zip_code: str, db: AsyncSession = Depends(get_db)): + """Return the House rep and senators for a ZIP code. + Step 1: Nominatim (OpenStreetMap) — ZIP → lat/lng. + Step 2: TIGERweb Legislative identify — lat/lng → congressional district. + """ + if not re.fullmatch(r"\d{5}", zip_code): + raise HTTPException(status_code=400, detail="ZIP code must be 5 digits") + + state_code: str | None = None + district_num: str | None = None + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + # Step 1: ZIP → lat/lng + r1 = await client.get( + "https://nominatim.openstreetmap.org/search", + params={"postalcode": zip_code, "country": "US", "format": "json", "limit": "1"}, + headers={"User-Agent": "PocketVeto/1.0"}, + ) + places = r1.json() if r1.status_code == 200 else [] + if not places: + logger.warning("Nominatim: no result for ZIP %s", zip_code) + return [] + + lat = places[0]["lat"] + lng = places[0]["lon"] + + # Step 2: lat/lng → congressional district via TIGERweb identify (all layers) + half = 0.5 + r2 = await client.get( + "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Legislative/MapServer/identify", + params={ + "f": "json", + "geometry": f"{lng},{lat}", + "geometryType": "esriGeometryPoint", + "sr": "4326", + "layers": "all", + "tolerance": "2", + "mapExtent": f"{float(lng)-half},{float(lat)-half},{float(lng)+half},{float(lat)+half}", + "imageDisplay": "100,100,96", + }, + ) + if r2.status_code != 200: + logger.warning("TIGERweb returned %s for ZIP %s", r2.status_code, zip_code) + return [] + + identify_results = r2.json().get("results", []) + logger.info( + "TIGERweb ZIP %s layers: %s", + zip_code, [r.get("layerName") for r in identify_results], + ) + + for item in identify_results: + if "Congressional" not in (item.get("layerName") or ""): + continue + attrs = item.get("attributes", {}) + # GEOID = 2-char state FIPS + 2-char district (e.g. "1218" = FL-18) + geoid = str(attrs.get("GEOID") or "").strip() + if len(geoid) == 4: + state_fips = geoid[:2] + district_fips = geoid[2:] + state_code = _FIPS_TO_STATE.get(state_fips) + district_num = str(int(district_fips)) if district_fips.strip("0") else None + if state_code: + break + + # Fallback: explicit field names + cd_field = next((k for k in attrs if re.match(r"CD\d+FP$", k)), None) + state_field = next((k for k in attrs if "STATEFP" in k.upper()), None) + if cd_field and state_field: + state_fips = str(attrs[state_field]).zfill(2) + district_fips = str(attrs[cd_field]) + state_code = _FIPS_TO_STATE.get(state_fips) + district_num = str(int(district_fips)) if district_fips.strip("0") else None + if state_code: + break + + if not state_code: + logger.warning( + "ZIP %s: no CD found. Layers: %s", + zip_code, [r.get("layerName") for r in identify_results], + ) + + except Exception as exc: + logger.warning("ZIP lookup error for %s: %s", zip_code, exc) + return [] + + if not state_code: + return [] + + members: list[MemberSchema] = [] + seen: set[str] = set() + + if district_num: + result = await db.execute( + select(Member).where( + Member.state == state_code, + Member.district == district_num, + Member.chamber == "House of Representatives", + ) + ) + member = result.scalar_one_or_none() + if member: + seen.add(member.bioguide_id) + members.append(MemberSchema.model_validate(member)) + else: + # At-large states (AK, DE, MT, ND, SD, VT, WY) + result = await db.execute( + select(Member).where( + Member.state == state_code, + Member.chamber == "House of Representatives", + ).limit(1) + ) + member = result.scalar_one_or_none() + if member: + seen.add(member.bioguide_id) + members.append(MemberSchema.model_validate(member)) + + result = await db.execute( + select(Member).where( + Member.state == state_code, + Member.chamber == "Senate", + ) + ) + for member in result.scalars().all(): + if member.bioguide_id not in seen: + seen.add(member.bioguide_id) + members.append(MemberSchema.model_validate(member)) + + return members + + @router.get("", response_model=PaginatedResponse[MemberSchema]) async def list_members( chamber: Optional[str] = Query(None), diff --git a/backend/app/config.py b/backend/app/config.py index 4894732..9996e14 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,10 @@ class Settings(BaseSettings): OLLAMA_BASE_URL: str = "http://host.docker.internal:11434" OLLAMA_MODEL: str = "llama3.1" + # Google Civic Information API (zip → representative lookup) + # Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com + CIVIC_API_KEY: str = "" + # News NEWSAPI_KEY: str = "" diff --git a/backend/app/services/congress_api.py b/backend/app/services/congress_api.py index 57b02f3..774444f 100644 --- a/backend/app/services/congress_api.py +++ b/backend/app/services/congress_api.py @@ -126,6 +126,34 @@ def parse_bill_from_api(data: dict, congress: int) -> dict: } +_STATE_NAME_TO_CODE: dict[str, str] = { + "Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", + "California": "CA", "Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", + "Florida": "FL", "Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", + "Illinois": "IL", "Indiana": "IN", "Iowa": "IA", "Kansas": "KS", + "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME", "Maryland": "MD", + "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS", + "Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", + "New Hampshire": "NH", "New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", + "North Carolina": "NC", "North Dakota": "ND", "Ohio": "OH", "Oklahoma": "OK", + "Oregon": "OR", "Pennsylvania": "PA", "Rhode Island": "RI", "South Carolina": "SC", + "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX", "Utah": "UT", + "Vermont": "VT", "Virginia": "VA", "Washington": "WA", "West Virginia": "WV", + "Wisconsin": "WI", "Wyoming": "WY", + "American Samoa": "AS", "Guam": "GU", "Northern Mariana Islands": "MP", + "Puerto Rico": "PR", "Virgin Islands": "VI", "District of Columbia": "DC", +} + + +def _normalize_state(state: str | None) -> str | None: + if not state: + return None + s = state.strip() + if len(s) == 2: + return s.upper() + return _STATE_NAME_TO_CODE.get(s, s) + + def parse_member_from_api(data: dict) -> dict: """Normalize raw API member list data into our model fields.""" terms = data.get("terms", {}).get("item", []) @@ -136,9 +164,9 @@ def parse_member_from_api(data: dict) -> dict: "first_name": data.get("firstName"), "last_name": data.get("lastName"), "party": data.get("partyName") or None, - "state": data.get("state"), + "state": _normalize_state(data.get("state")), "chamber": current_term.get("chamber"), - "district": str(current_term.get("district")) if current_term.get("district") else None, + "district": str(data.get("district")) if data.get("district") else None, "photo_url": data.get("depiction", {}).get("imageUrl"), "official_url": data.get("officialWebsiteUrl"), } diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 005d22a..ad68990 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -418,6 +418,7 @@ def generate_draft_letter( selected_points: list[str], include_citations: bool, zip_code: str | None, + rep_name: str | None = None, llm_provider: str | None = None, llm_model: str | None = None, ) -> str: @@ -436,12 +437,19 @@ def generate_draft_letter( location_line = f"The constituent is writing from ZIP code {zip_code}." if zip_code else "" + if rep_name: + title = "Senator" if recipient == "senate" else "Representative" + salutation_instruction = f'- Open with "Dear {title} {rep_name},"' + else: + salutation_instruction = f'- Open with "Dear {chamber_word} Member,"' + prompt = f"""Write a short constituent letter to a {chamber_word} member of Congress. RULES: - {tone_instruction} - 6 to 12 sentences total. -- First sentence must be a clear, direct ask: "Please vote {vote_word} on {bill_label}." +- {salutation_instruction} +- Second sentence must be a clear, direct ask: "Please vote {vote_word} on {bill_label}." - The body must reference ONLY the points listed below — do not invent any other claims or facts. - {citation_instruction} - Close with a brief sign-off and the placeholder "[Your Name]". diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 8f53570..55d5084 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -70,6 +70,10 @@ celery_app.conf.update( "task": "app.workers.member_interest.calculate_all_member_trend_scores", "schedule": crontab(hour=3, minute=0), }, + "sync-members": { + "task": "app.workers.congress_poller.sync_members", + "schedule": crontab(hour=1, minute=0), # 1 AM UTC daily — refreshes chamber/district/contact info + }, "fetch-actions-active-bills": { "task": "app.workers.congress_poller.fetch_actions_for_active_bills", "schedule": crontab(hour=4, minute=0), # 4 AM UTC, after trend + member scoring diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py index d49b53b..ea1fe6d 100644 --- a/backend/app/workers/notification_utils.py +++ b/backend/app/workers/notification_utils.py @@ -35,7 +35,9 @@ def is_referral_action(action_text: str) -> bool: return any(kw in t for kw in _REFERRAL_KEYWORDS) -def _build_payload(bill, action_summary: str, milestone_tier: str = "progress") -> dict: +def _build_payload( + bill, action_summary: str, milestone_tier: str = "progress", source: str = "bill_follow" +) -> dict: from app.config import settings base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/") return { @@ -44,6 +46,7 @@ def _build_payload(bill, action_summary: str, milestone_tier: str = "progress") "brief_summary": (action_summary or "")[:300], "bill_url": f"{base_url}/bills/{bill.bill_id}", "milestone_tier": milestone_tier, + "source": source, } @@ -69,7 +72,7 @@ def emit_bill_notification( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier) + payload = _build_payload(bill, action_summary, milestone_tier, source="bill_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): @@ -100,7 +103,7 @@ def emit_member_follow_notifications( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier) + payload = _build_payload(bill, action_summary, milestone_tier, source="member_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): @@ -140,7 +143,7 @@ def emit_topic_follow_notifications( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier) + payload = _build_payload(bill, action_summary, milestone_tier, source="topic_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): diff --git a/frontend/app/bills/page.tsx b/frontend/app/bills/page.tsx index fb43295..9d85ebc 100644 --- a/frontend/app/bills/page.tsx +++ b/frontend/app/bills/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useSearchParams } from "next/navigation"; -import { Search } from "lucide-react"; +import { FileText, Search } from "lucide-react"; import { useBills } from "@/lib/hooks/useBills"; import { BillCard } from "@/components/shared/BillCard"; @@ -19,12 +19,22 @@ export default function BillsPage() { const [q, setQ] = useState(searchParams.get("q") ?? ""); const [chamber, setChamber] = useState(searchParams.get("chamber") ?? ""); const [topic, setTopic] = useState(searchParams.get("topic") ?? ""); + const [hasText, setHasText] = useState(false); const [page, setPage] = useState(1); + // Sync URL params → state so tag/topic links work when already on this page + useEffect(() => { + setQ(searchParams.get("q") ?? ""); + setChamber(searchParams.get("chamber") ?? ""); + setTopic(searchParams.get("topic") ?? ""); + setPage(1); + }, [searchParams]); + const params = { ...(q && { q }), ...(chamber && { chamber }), ...(topic && { topic }), + ...(hasText && { has_document: true }), page, per_page: 20, sort: "latest_action_date", @@ -67,6 +77,18 @@ export default function BillsPage() { {TOPICS.slice(1).map((t) => )} + {/* Results */} diff --git a/frontend/app/collections/page.tsx b/frontend/app/collections/page.tsx index 7663192..60e7034 100644 --- a/frontend/app/collections/page.tsx +++ b/frontend/app/collections/page.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react"; import { collectionsAPI } from "@/lib/api"; +import { HelpTip } from "@/components/shared/HelpTip"; import type { Collection } from "@/lib/types"; export default function CollectionsPage() { @@ -49,9 +50,15 @@ export default function CollectionsPage() { return (
-
- -

My Collections

+
+
+ +

My Collections

+
+

+ A collection is a named group of bills you curate — like a playlist for legislation. + Share any collection via a link; no account required to view. +

+ + + {repsFetching && ( +

Looking up representatives…

+ )} + {repsError && ( +

Could not look up representatives. Check your ZIP and try again.

+ )} + {isValidZip && !repsFetching && myReps && myReps.length === 0 && ( +

No representatives found for {submittedZip}.

+ )} + {myReps && myReps.length > 0 && ( +
+

Representatives for ZIP {submittedZip}

+
+ {myReps.map((rep) => ( + + ))} +
+
+ )} +
+ + {/* Filters */}
@@ -103,23 +195,7 @@ export default function MembersPage() {
{data?.total ?? 0} members
{data?.items?.map((member) => ( -
-
- - {member.name} - -
- {member.party && ( - - {member.party} - - )} - {member.state && {member.state}} - {member.chamber && {member.chamber}} -
-
- -
+ ))}
{data && data.pages > 1 && ( diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index 8087eb2..78ed027 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; +import { useFollows } from "@/lib/hooks/useFollows"; import type { NotificationEvent } from "@/lib/types"; const AUTH_METHODS = [ @@ -50,6 +51,11 @@ export default function NotificationsPage() { staleTime: 60 * 1000, }); + const { data: follows = [] } = useFollows(); + const directlyFollowedBillIds = new Set( + follows.filter((f) => f.follow_type === "bill").map((f) => f.follow_value) + ); + const update = useMutation({ mutationFn: (data: Parameters[0]) => notificationsAPI.updateSettings(data), @@ -497,59 +503,106 @@ export default function NotificationsPage() { {/* Notification History */} -
-
-
-

- Recent Alerts -

-

Last 50 notification events for your account.

-
-
+ {(() => { + const directEvents = history.filter((e: NotificationEvent) => { + const src = (e.payload as Record)?.source as string | undefined; + if (src === "topic_follow") return false; + if (src === "bill_follow" || src === "member_follow") return true; + // Legacy events (no source field): treat as direct if bill is followed + return directlyFollowedBillIds.has(e.bill_id); + }); + const topicEvents = history.filter((e: NotificationEvent) => { + const src = (e.payload as Record)?.source as string | undefined; + if (src === "topic_follow") return true; + if (src) return false; + return !directlyFollowedBillIds.has(e.bill_id); + }); - {historyLoading ? ( -

Loading history…

- ) : history.length === 0 ? ( -

- No events yet. Follow some bills and check back after the next poll. -

- ) : ( -
- {history.map((event: NotificationEvent) => { - const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" }; - const Icon = meta.icon; - const payload = event.payload ?? {}; - return ( -
- -
-
- {meta.label} - {payload.bill_label && ( - - {payload.bill_label} - - )} - {timeAgo(event.created_at)} -
- {payload.bill_title && ( -

{payload.bill_title}

- )} - {payload.brief_summary && ( -

{payload.brief_summary}

- )} -
- - {event.dispatched_at ? "✓" : "⏳"} - + const EventRow = ({ event, showDispatch }: { event: NotificationEvent; showDispatch: boolean }) => { + const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" }; + const Icon = meta.icon; + const p = (event.payload ?? {}) as Record; + const billLabel = p.bill_label as string | undefined; + const billTitle = p.bill_title as string | undefined; + const briefSummary = p.brief_summary as string | undefined; + return ( +
+ +
+
+ {meta.label} + {billLabel && ( + + {billLabel} + + )} + {timeAgo(event.created_at)}
- ); - })} -
- )} -
+ {billTitle && ( +

{billTitle}

+ )} + {briefSummary && ( +

{briefSummary}

+ )} +
+ {showDispatch && ( + + {event.dispatched_at ? "✓" : "⏳"} + + )} +
+ ); + }; + + return ( + <> +
+
+

+ Recent Alerts +

+

+ Notifications for bills and members you directly follow. Last 50 events. +

+
+ {historyLoading ? ( +

Loading history…

+ ) : directEvents.length === 0 ? ( +

+ No alerts yet. Follow some bills and check back after the next poll. +

+ ) : ( +
+ {directEvents.map((event: NotificationEvent) => ( + + ))} +
+ )} +
+ + {topicEvents.length > 0 && ( +
+
+

+ Based on your topic follows +

+

+ Bills matching topics you follow that have had recent activity. + These are delivered as notifications — follow a bill directly to change its alert mode. +

+
+
+ {topicEvents.map((event: NotificationEvent) => ( + + ))} +
+
+ )} + + ); + })()}
); } diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index c117496..c157411 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -605,6 +605,7 @@ export default function SettingsPage() { { key: "govinfo", label: "GovInfo API" }, { key: "newsapi", label: "NewsAPI.org" }, { key: "google_news", label: "Google News RSS" }, + { key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" }, ].map(({ key, label }) => { const r = healthData[key]; if (!r) return null; diff --git a/frontend/components/bills/DraftLetterPanel.tsx b/frontend/components/bills/DraftLetterPanel.tsx index 682ee5f..e49da77 100644 --- a/frontend/components/bills/DraftLetterPanel.tsx +++ b/frontend/components/bills/DraftLetterPanel.tsx @@ -1,9 +1,10 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { ChevronDown, ChevronRight, Copy, Check, Loader2, PenLine } from "lucide-react"; -import type { BriefSchema, CitedPoint } from "@/lib/types"; -import { billsAPI } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, ChevronRight, Copy, Check, ExternalLink, Loader2, Phone, PenLine } from "lucide-react"; +import type { BriefSchema, CitedPoint, Member } from "@/lib/types"; +import { billsAPI, membersAPI } from "@/lib/api"; import { useIsFollowing } from "@/lib/hooks/useFollows"; interface DraftLetterPanelProps { @@ -27,6 +28,15 @@ function chamberToRecipient(chamber?: string): "house" | "senate" { return chamber?.toLowerCase() === "senate" ? "senate" : "house"; } +function formatRepName(member: Member): string { + // DB stores name as "Last, First" — convert to "First Last" for the letter + if (member.name.includes(", ")) { + const [last, first] = member.name.split(", "); + return `${first} ${last}`; + } + return member.name; +} + export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelProps) { const [open, setOpen] = useState(false); const existing = useIsFollowing("bill", billId); @@ -53,6 +63,27 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + // Zip → rep lookup (debounced via React Query enabled flag) + const zipTrimmed = zipCode.trim(); + const isValidZip = /^\d{5}$/.test(zipTrimmed); + const { data: zipReps, isFetching: zipFetching } = useQuery({ + queryKey: ["members-by-zip", zipTrimmed], + queryFn: () => membersAPI.byZip(zipTrimmed), + enabled: isValidZip, + staleTime: 24 * 60 * 60 * 1000, + retry: false, + }); + + // Filter reps to match the bill's chamber + const relevantReps = zipReps?.filter((m) => + recipient === "senate" + ? m.chamber === "Senate" + : m.chamber === "House of Representatives" + ) ?? []; + + // Use first matched rep's name for the letter salutation + const repName = relevantReps.length > 0 ? formatRepName(relevantReps[0]) : undefined; + const keyPoints = brief.key_points ?? []; const risks = brief.risks ?? []; const allPoints = [ @@ -96,6 +127,7 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro selected_points: selectedPoints, include_citations: includeCitations, zip_code: zipCode.trim() || undefined, + rep_name: repName, }); setDraft(result.draft); } catch (err: unknown) { @@ -253,20 +285,23 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
{/* Options row */} -
-
- setZipCode(e.target.value)} - placeholder="ZIP code" - maxLength={10} - className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground" - /> +
+
+
+ setZipCode(e.target.value)} + placeholder="ZIP code" + maxLength={10} + className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground" + /> + {zipFetching && } +

optional · not stored

-
+ {/* Rep lookup results */} + {isValidZip && !zipFetching && relevantReps.length > 0 && ( +
+

+ Your {recipient === "senate" ? "senators" : "representative"} +

+ {relevantReps.map((rep) => ( +
+ {rep.photo_url && ( + {rep.name} + )} +
+

{formatRepName(rep)}

+ {rep.party && ( +

{rep.party} · {rep.state}

+ )} +
+
+ {rep.phone && ( + + + {rep.phone} + + )} + {rep.official_url && ( + + + Contact + + )} +
+
+ ))} + {repName && ( +

+ Letter will be addressed to{" "} + {recipient === "senate" ? "Senator" : "Representative"} {repName}. +

+ )} +
+ )} + + {isValidZip && !zipFetching && relevantReps.length === 0 && zipReps !== undefined && ( +

+ Could not find your {recipient === "senate" ? "senators" : "representative"} for that ZIP. + The letter will use a generic salutation. +

+ )} + {/* Generate button */}
diff --git a/frontend/components/shared/HelpTip.tsx b/frontend/components/shared/HelpTip.tsx new file mode 100644 index 0000000..42323dc --- /dev/null +++ b/frontend/components/shared/HelpTip.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface HelpTipProps { + content: string; + className?: string; +} + +export function HelpTip({ content, className }: HelpTipProps) { + const [visible, setVisible] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!visible) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setVisible(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [visible]); + + return ( +
+ + {visible && ( +
+ {content} +
+ )} +
+ ); +} diff --git a/frontend/components/shared/Sidebar.tsx b/frontend/components/shared/Sidebar.tsx index 950d455..f8f36ec 100644 --- a/frontend/components/shared/Sidebar.tsx +++ b/frontend/components/shared/Sidebar.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Bookmark, + HelpCircle, LayoutDashboard, FileText, Users, @@ -28,6 +29,7 @@ const NAV = [ { href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true }, { href: "/collections", label: "Collections", icon: Bookmark, adminOnly: false, requiresAuth: true }, { href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true }, + { href: "/how-it-works", label: "How it works", icon: HelpCircle, adminOnly: false, requiresAuth: false }, { href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false }, ]; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e2fb638..7f3f291 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -85,6 +85,7 @@ export const billsAPI = { 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), }; @@ -132,6 +133,8 @@ export const membersAPI = { apiClient.get>("/api/members", { params }).then((r) => r.data), get: (id: string) => apiClient.get(`/api/members/${id}`).then((r) => r.data), + byZip: (zip: string) => + apiClient.get(`/api/members/by-zip/${zip}`).then((r) => r.data), getBills: (id: string, params?: Record) => apiClient.get>(`/api/members/${id}/bills`, { params }).then((r) => r.data), getTrend: (id: string, days?: number) =>