feat: ZIP → rep lookup, member page redesign, letter improvements

ZIP lookup (GET /api/members/by-zip/{zip}):
- Two-step geocoding: Nominatim (ZIP → lat/lng) then Census TIGERweb
  Legislative identify (lat/lng → congressional district via GEOID)
- Handles at-large states (AK, DE, MT, ND, SD, VT, WY)
- Added rep_lookup health check to admin External API Health panel

congress_api.py fixes:
- parse_member_from_api: normalize state full name → 2-letter code
  (Congress.gov returns "Florida", DB expects "FL")
- parse_member_from_api: read district from top-level data field,
  not current_term (district is not inside the term object)

Celery beat: schedule sync_members daily at 1 AM UTC so chamber,
district, and contact info stay current without manual triggering

Members page redesign: photo avatars, party/state/chamber chips,
phone + website links, ZIP lookup form to find your reps

Draft letter improvements: pass rep_name from ZIP lookup so letter
opens with "Dear Representative Franklin," instead of generic salutation;
add has_document filter to bills list endpoint

UX additions: HelpTip component, How It Works page, "How it works"
sidebar nav link, collections page description copy

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-02 15:47:46 -05:00
parent 5bb0c2b8ec
commit 48771287d3
20 changed files with 899 additions and 116 deletions

View File

@@ -47,6 +47,12 @@ GEMINI_MODEL=gemini-1.5-pro
OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3.1 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 ───────────────────────────────────────────────────────────────────── # ─── News ─────────────────────────────────────────────────────────────────────
# Free key (100 req/day): https://newsapi.org/register # Free key (100 req/day): https://newsapi.org/register
NEWSAPI_KEY= NEWSAPI_KEY=

View File

@@ -272,9 +272,10 @@ async def api_health(current_user: User = Depends(get_current_admin)):
asyncio.to_thread(_test_govinfo), asyncio.to_thread(_test_govinfo),
asyncio.to_thread(_test_newsapi), asyncio.to_thread(_test_newsapi),
asyncio.to_thread(_test_gnews), asyncio.to_thread(_test_gnews),
asyncio.to_thread(_test_rep_lookup),
return_exceptions=True, return_exceptions=True,
) )
keys = ["congress_gov", "govinfo", "newsapi", "google_news"] keys = ["congress_gov", "govinfo", "newsapi", "google_news", "rep_lookup"]
return { return {
k: r if isinstance(r, dict) else {"status": "error", "detail": str(r)} k: r if isinstance(r, dict) else {"status": "error", "detail": str(r)}
for k, r in zip(keys, results) for k, r in zip(keys, results)
@@ -370,6 +371,54 @@ def _test_gnews() -> dict:
return {"status": "error", "detail": str(exc)} 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}") @router.get("/task-status/{task_id}")
async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)): async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)):
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app

View File

@@ -35,7 +35,8 @@ class DraftLetterRequest(BaseModel):
tone: Literal["short", "polite", "firm"] tone: Literal["short", "polite", "firm"]
selected_points: list[str] selected_points: list[str]
include_citations: bool = True 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): class DraftLetterResponse(BaseModel):
@@ -50,6 +51,7 @@ async def list_bills(
topic: Optional[str] = Query(None), topic: Optional[str] = Query(None),
sponsor_id: Optional[str] = Query(None), sponsor_id: Optional[str] = Query(None),
q: Optional[str] = Query(None), q: Optional[str] = Query(None),
has_document: Optional[bool] = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), per_page: int = Query(20, ge=1, le=100),
sort: str = Query("latest_action_date"), sort: str = Query("latest_action_date"),
@@ -80,6 +82,12 @@ async def list_bills(
Bill.short_title.ilike(f"%{q}%"), 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 total
count_query = select(func.count()).select_from(query.subquery()) 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, selected_points=body.selected_points,
include_citations=body.include_citations, include_citations=body.include_citations,
zip_code=body.zip_code, zip_code=body.zip_code,
rep_name=body.rep_name,
llm_provider=llm_provider_override, llm_provider=llm_provider_override,
llm_model=llm_model_override, llm_model=llm_model_override,
) )

View File

@@ -1,7 +1,23 @@
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional 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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, func, or_, select from sqlalchemy import desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,6 +36,139 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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]) @router.get("", response_model=PaginatedResponse[MemberSchema])
async def list_members( async def list_members(
chamber: Optional[str] = Query(None), chamber: Optional[str] = Query(None),

View File

@@ -39,6 +39,10 @@ class Settings(BaseSettings):
OLLAMA_BASE_URL: str = "http://host.docker.internal:11434" OLLAMA_BASE_URL: str = "http://host.docker.internal:11434"
OLLAMA_MODEL: str = "llama3.1" 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 # News
NEWSAPI_KEY: str = "" NEWSAPI_KEY: str = ""

View File

@@ -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: def parse_member_from_api(data: dict) -> dict:
"""Normalize raw API member list data into our model fields.""" """Normalize raw API member list data into our model fields."""
terms = data.get("terms", {}).get("item", []) terms = data.get("terms", {}).get("item", [])
@@ -136,9 +164,9 @@ def parse_member_from_api(data: dict) -> dict:
"first_name": data.get("firstName"), "first_name": data.get("firstName"),
"last_name": data.get("lastName"), "last_name": data.get("lastName"),
"party": data.get("partyName") or None, "party": data.get("partyName") or None,
"state": data.get("state"), "state": _normalize_state(data.get("state")),
"chamber": current_term.get("chamber"), "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"), "photo_url": data.get("depiction", {}).get("imageUrl"),
"official_url": data.get("officialWebsiteUrl"), "official_url": data.get("officialWebsiteUrl"),
} }

View File

@@ -418,6 +418,7 @@ def generate_draft_letter(
selected_points: list[str], selected_points: list[str],
include_citations: bool, include_citations: bool,
zip_code: str | None, zip_code: str | None,
rep_name: str | None = None,
llm_provider: str | None = None, llm_provider: str | None = None,
llm_model: str | None = None, llm_model: str | None = None,
) -> str: ) -> 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 "" 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. prompt = f"""Write a short constituent letter to a {chamber_word} member of Congress.
RULES: RULES:
- {tone_instruction} - {tone_instruction}
- 6 to 12 sentences total. - 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. - The body must reference ONLY the points listed below — do not invent any other claims or facts.
- {citation_instruction} - {citation_instruction}
- Close with a brief sign-off and the placeholder "[Your Name]". - Close with a brief sign-off and the placeholder "[Your Name]".

View File

@@ -70,6 +70,10 @@ celery_app.conf.update(
"task": "app.workers.member_interest.calculate_all_member_trend_scores", "task": "app.workers.member_interest.calculate_all_member_trend_scores",
"schedule": crontab(hour=3, minute=0), "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": { "fetch-actions-active-bills": {
"task": "app.workers.congress_poller.fetch_actions_for_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 "schedule": crontab(hour=4, minute=0), # 4 AM UTC, after trend + member scoring

View File

@@ -35,7 +35,9 @@ def is_referral_action(action_text: str) -> bool:
return any(kw in t for kw in _REFERRAL_KEYWORDS) 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 from app.config import settings
base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/") base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
return { return {
@@ -44,6 +46,7 @@ def _build_payload(bill, action_summary: str, milestone_tier: str = "progress")
"brief_summary": (action_summary or "")[:300], "brief_summary": (action_summary or "")[:300],
"bill_url": f"{base_url}/bills/{bill.bill_id}", "bill_url": f"{base_url}/bills/{bill.bill_id}",
"milestone_tier": milestone_tier, "milestone_tier": milestone_tier,
"source": source,
} }
@@ -69,7 +72,7 @@ def emit_bill_notification(
if not followers: if not followers:
return 0 return 0
payload = _build_payload(bill, action_summary, milestone_tier) payload = _build_payload(bill, action_summary, milestone_tier, source="bill_follow")
count = 0 count = 0
for follow in followers: for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
@@ -100,7 +103,7 @@ def emit_member_follow_notifications(
if not followers: if not followers:
return 0 return 0
payload = _build_payload(bill, action_summary, milestone_tier) payload = _build_payload(bill, action_summary, milestone_tier, source="member_follow")
count = 0 count = 0
for follow in followers: for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
@@ -140,7 +143,7 @@ def emit_topic_follow_notifications(
if not followers: if not followers:
return 0 return 0
payload = _build_payload(bill, action_summary, milestone_tier) payload = _build_payload(bill, action_summary, milestone_tier, source="topic_follow")
count = 0 count = 0
for follow in followers: for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Search } from "lucide-react"; import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills"; import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard"; import { BillCard } from "@/components/shared/BillCard";
@@ -19,12 +19,22 @@ export default function BillsPage() {
const [q, setQ] = useState(searchParams.get("q") ?? ""); const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? ""); const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? ""); const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
const [hasText, setHasText] = useState(false);
const [page, setPage] = useState(1); 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 = { const params = {
...(q && { q }), ...(q && { q }),
...(chamber && { chamber }), ...(chamber && { chamber }),
...(topic && { topic }), ...(topic && { topic }),
...(hasText && { has_document: true }),
page, page,
per_page: 20, per_page: 20,
sort: "latest_action_date", sort: "latest_action_date",
@@ -67,6 +77,18 @@ export default function BillsPage() {
<option value="">All Topics</option> <option value="">All Topics</option>
{TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)} {TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)}
</select> </select>
<button
onClick={() => { setHasText((v) => !v); setPage(1); }}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border transition-colors ${
hasText
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="Show only bills with published text"
>
<FileText className="w-3.5 h-3.5" />
Has text
</button>
</div> </div>
{/* Results */} {/* Results */}

View File

@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";
import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react"; import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
import { collectionsAPI } from "@/lib/api"; import { collectionsAPI } from "@/lib/api";
import { HelpTip } from "@/components/shared/HelpTip";
import type { Collection } from "@/lib/types"; import type { Collection } from "@/lib/types";
export default function CollectionsPage() { export default function CollectionsPage() {
@@ -49,9 +50,15 @@ export default function CollectionsPage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div>
<Bookmark className="w-5 h-5 text-primary" /> <div className="flex items-center gap-2">
<h1 className="text-xl font-bold">My Collections</h1> <Bookmark className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">My Collections</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
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.
</p>
</div> </div>
<button <button
onClick={() => setShowForm((v) => !v)} onClick={() => setShowForm((v) => !v)}
@@ -87,7 +94,7 @@ export default function CollectionsPage() {
className="rounded" className="rounded"
/> />
<span>Public collection</span> <span>Public collection</span>
<span className="text-xs text-muted-foreground">(share link works either way)</span> <HelpTip content="Share links work whether or not a collection is public. Marking it public signals it may appear in a future public directory — private collections are invisible to anyone without your link." />
</label> </label>
{formError && <p className="text-xs text-destructive">{formError}</p>} {formError && <p className="text-xs text-destructive">{formError}</p>}
<div className="flex gap-2"> <div className="flex gap-2">

View File

@@ -0,0 +1,209 @@
import Link from "next/link";
import {
Bell,
Bookmark,
Calendar,
Clock,
FileText,
Heart,
HelpCircle,
Rss,
Shield,
Share2,
Zap,
} from "lucide-react";
function Section({ id, title, icon: Icon, children }: {
id: string;
title: string;
icon: React.ElementType;
children: React.ReactNode;
}) {
return (
<section id={id} className="bg-card border border-border rounded-lg p-6 space-y-4 scroll-mt-6">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</h2>
{children}
</section>
);
}
function Item({ icon: Icon, color, title, children }: {
icon: React.ElementType;
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="flex gap-3">
<div className={`mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center ${color}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{children}</p>
</div>
</div>
);
}
export default function HowItWorksPage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<HelpCircle className="w-5 h-5" /> How it works
</h1>
<p className="text-muted-foreground text-sm mt-1">
A quick guide to PocketVeto&apos;s features.
</p>
{/* Jump links */}
<div className="flex flex-wrap gap-2 mt-3">
{[
{ href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" },
{ href: "#bills", label: "Bills" },
].map(({ href, label }) => (
<a
key={href}
href={href}
className="text-xs px-2.5 py-1 bg-muted rounded-full hover:bg-accent transition-colors"
>
{label}
</a>
))}
</div>
</div>
{/* Following */}
<Section id="follow" title="Following bills" icon={Heart}>
<p className="text-sm text-muted-foreground">
Follow any bill to track it. PocketVeto checks for changes new text, amendments, status
updates and notifies you through your configured channels. Three modes let you tune the
signal to your interest level.
</p>
<div className="space-y-3">
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Following (neutral)">
Alerts on all material changes: new text published, amendments filed, and status updates
like floor votes or committee referrals.
</Item>
<Item icon={Shield} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Pocket Veto">
Alerts only when the bill advances toward becoming law new official text, floor
scheduling, or committee passage. Useful when you oppose a bill and only need to act
if it gains traction.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Pocket Boost">
All the alerts of neutral mode, plus periodic reminders to contact your representative.
Use this when you actively support a bill and want to stay engaged.
</Item>
</div>
<p className="text-xs text-muted-foreground">
You can also follow <strong>members</strong> (get alerts when they sponsor new bills) and{" "}
<strong>topics</strong> (get alerts when new bills matching that topic are briefed).
Member and topic follows use the neutral mode only.
</p>
</Section>
{/* Collections */}
<Section id="collections" title="Collections" icon={Bookmark}>
<p className="text-sm text-muted-foreground">
A collection is a named, curated group of bills like a playlist for legislation. Use
collections to track a policy area, build a watchlist for an advocacy campaign, or share
research with colleagues.
</p>
<div className="space-y-3">
<Item icon={Bookmark} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Creating a collection">
Give it a name (e.g. &ldquo;Healthcare Watch&rdquo;) and add bills from any bill detail
page using the bookmark icon next to the Follow button.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
Every collection has a unique share link. Anyone with the link can view the collection
no account required. The link works whether the collection is public or private.
</Item>
</div>
<p className="text-xs text-muted-foreground">
<strong>Public vs. private:</strong> Both have share links. Marking a collection public
signals it may appear in a future public directory; private collections are invisible to
anyone without your link.
</p>
</Section>
{/* Notifications */}
<Section id="notifications" title="Notifications" icon={Bell}>
<p className="text-sm text-muted-foreground">
PocketVeto delivers alerts through two independent channels use either or both.
</p>
<div className="space-y-3">
<Item icon={Bell} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Push via ntfy">
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
ntfy
</a>
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
and receive real-time alerts on any device with the ntfy app.
</Item>
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
Pause push notifications during set hours (e.g. 10 PM 8 AM). Events that arrive
during quiet hours are queued and sent as a batch when the window ends.
</Item>
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
Instead of one push per event, receive a single bundled summary on a daily or weekly
schedule. Your RSS feed is always real-time regardless of this setting.
</Item>
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
</Item>
</div>
</Section>
{/* AI Briefs */}
<Section id="briefs" title="AI Briefs" icon={FileText}>
<p className="text-sm text-muted-foreground">
For bills with published official text, PocketVeto generates a plain-English AI brief.
</p>
<div className="space-y-3">
<Item icon={FileText} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="What's in a brief">
A plain-English summary, key policy points with references to specific bill sections
(§ chips), and a risks section that flags potential unintended consequences or contested
provisions.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
Click the share icon in the brief panel to copy a public link. Anyone can read the
brief at that URL no login required.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
Use the Draft Letter panel (below the brief) to generate a personalised letter to your
representative based on the brief&apos;s key points.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Briefs are only generated for bills where GovInfo has published official text. Bills
without text show a &ldquo;No text&rdquo; badge on their card.
</p>
</Section>
{/* Bills */}
<Section id="bills" title="Browsing bills" icon={FileText}>
<p className="text-sm text-muted-foreground">
The <Link href="/bills" className="text-primary hover:underline">Bills</Link> page lists
all tracked legislation. Use the filters to narrow your search.
</p>
<div className="space-y-2 text-xs text-muted-foreground">
<p><strong className="text-foreground">Search</strong> matches bill ID, title, and short title.</p>
<p><strong className="text-foreground">Chamber</strong> House or Senate.</p>
<p><strong className="text-foreground">Topic</strong> AI-tagged policy area (healthcare, defense, etc.).</p>
<p><strong className="text-foreground">Has text</strong> show only bills with published official text available for AI briefing.</p>
</div>
<p className="text-xs text-muted-foreground">
Clicking a topic tag on any bill or following page takes you directly to that filtered
view on the Bills page.
</p>
</Section>
</div>
);
}

View File

@@ -2,30 +2,54 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Search, Heart } from "lucide-react"; import { MapPin, Search, Heart } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMembers, useMember } from "@/lib/hooks/useMembers"; import { useMembers, useMember } from "@/lib/hooks/useMembers";
import { useFollows } from "@/lib/hooks/useFollows"; import { useFollows } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { FollowButton } from "@/components/shared/FollowButton"; import { FollowButton } from "@/components/shared/FollowButton";
import { membersAPI } from "@/lib/api";
import { cn, partyBadgeColor } from "@/lib/utils"; import { cn, partyBadgeColor } from "@/lib/utils";
import type { Member } from "@/lib/types";
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) { function MemberCard({ member }: { member: Member }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return ( return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3"> <div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex items-start gap-3 flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors"> {member.photo_url ? (
{member.name} <img src={member.photo_url} alt={member.name} className="w-10 h-10 rounded-full object-cover shrink-0 border border-border" />
</Link> ) : (
<div className="flex items-center gap-1.5 mt-1"> <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground shrink-0">
{member.party && ( {member.name[0]}
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}> </div>
{member.party} )}
</span> <div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
{(member.phone || member.official_url) && (
<div className="flex items-center gap-2 mt-1">
{member.phone && (
<a href={`tel:${member.phone.replace(/\D/g, "")}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
{member.phone}
</a>
)}
{member.official_url && (
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
Contact
</a>
)}
</div>
)} )}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div> </div>
</div> </div>
<FollowButton type="member" value={member.bioguide_id} /> <FollowButton type="member" value={member.bioguide_id} />
@@ -33,11 +57,19 @@ function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
); );
} }
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return <MemberCard member={member} />;
}
export default function MembersPage() { export default function MembersPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [chamber, setChamber] = useState(""); const [chamber, setChamber] = useState("");
const [party, setParty] = useState(""); const [party, setParty] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [zipInput, setZipInput] = useState("");
const [submittedZip, setSubmittedZip] = useState("");
const { data, isLoading } = useMembers({ const { data, isLoading } = useMembers({
...(q && { q }), ...(chamber && { chamber }), ...(party && { party }), ...(q && { q }), ...(chamber && { chamber }), ...(party && { party }),
@@ -48,6 +80,20 @@ export default function MembersPage() {
const { data: follows } = useFollows(); const { data: follows } = useFollows();
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? []; const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
const isValidZip = /^\d{5}$/.test(submittedZip);
const { data: myReps, isFetching: repsFetching, error: repsError } = useQuery({
queryKey: ["members-by-zip", submittedZip],
queryFn: () => membersAPI.byZip(submittedZip),
enabled: isValidZip,
staleTime: 24 * 60 * 60 * 1000,
retry: false,
});
function handleZipSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmittedZip(zipInput.trim());
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -55,6 +101,52 @@ export default function MembersPage() {
<p className="text-muted-foreground text-sm mt-1">Browse current Congress members</p> <p className="text-muted-foreground text-sm mt-1">Browse current Congress members</p>
</div> </div>
{/* Zip lookup */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-primary" />
Find your representatives
</p>
<form onSubmit={handleZipSubmit} className="flex gap-2">
<input
type="text"
value={zipInput}
onChange={(e) => setZipInput(e.target.value)}
placeholder="Enter ZIP code"
maxLength={5}
className="px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary w-40"
/>
<button
type="submit"
disabled={!/^\d{5}$/.test(zipInput.trim())}
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Find
</button>
</form>
{repsFetching && (
<p className="text-sm text-muted-foreground">Looking up representatives</p>
)}
{repsError && (
<p className="text-sm text-destructive">Could not look up representatives. Check your ZIP and try again.</p>
)}
{isValidZip && !repsFetching && myReps && myReps.length === 0 && (
<p className="text-sm text-muted-foreground">No representatives found for {submittedZip}.</p>
)}
{myReps && myReps.length > 0 && (
<div className="space-y-2 pt-1">
<p className="text-xs text-muted-foreground">Representatives for ZIP {submittedZip}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{myReps.map((rep) => (
<MemberCard key={rep.bioguide_id} member={rep} />
))}
</div>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48"> <div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -103,23 +195,7 @@ export default function MembersPage() {
<div className="text-sm text-muted-foreground">{data?.total ?? 0} members</div> <div className="text-sm text-muted-foreground">{data?.total ?? 0} members</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{data?.items?.map((member) => ( {data?.items?.map((member) => (
<div key={member.bioguide_id} className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3"> <MemberCard key={member.bioguide_id} member={member} />
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
))} ))}
</div> </div>
{data && data.pages > 1 && ( {data && data.pages > 1 && (

View File

@@ -8,6 +8,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
import { useFollows } from "@/lib/hooks/useFollows";
import type { NotificationEvent } from "@/lib/types"; import type { NotificationEvent } from "@/lib/types";
const AUTH_METHODS = [ const AUTH_METHODS = [
@@ -50,6 +51,11 @@ export default function NotificationsPage() {
staleTime: 60 * 1000, 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({ const update = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) => mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data), notificationsAPI.updateSettings(data),
@@ -497,59 +503,106 @@ export default function NotificationsPage() {
</section> </section>
{/* Notification History */} {/* Notification History */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4"> {(() => {
<div className="flex items-center justify-between"> const directEvents = history.filter((e: NotificationEvent) => {
<div> const src = (e.payload as Record<string, unknown>)?.source as string | undefined;
<h2 className="font-semibold flex items-center gap-2"> if (src === "topic_follow") return false;
<Bell className="w-4 h-4" /> Recent Alerts if (src === "bill_follow" || src === "member_follow") return true;
</h2> // Legacy events (no source field): treat as direct if bill is followed
<p className="text-xs text-muted-foreground mt-1">Last 50 notification events for your account.</p> return directlyFollowedBillIds.has(e.bill_id);
</div> });
</div> const topicEvents = history.filter((e: NotificationEvent) => {
const src = (e.payload as Record<string, unknown>)?.source as string | undefined;
if (src === "topic_follow") return true;
if (src) return false;
return !directlyFollowedBillIds.has(e.bill_id);
});
{historyLoading ? ( const EventRow = ({ event, showDispatch }: { event: NotificationEvent; showDispatch: boolean }) => {
<p className="text-sm text-muted-foreground">Loading history</p> const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" };
) : history.length === 0 ? ( const Icon = meta.icon;
<p className="text-sm text-muted-foreground"> const p = (event.payload ?? {}) as Record<string, unknown>;
No events yet. Follow some bills and check back after the next poll. const billLabel = p.bill_label as string | undefined;
</p> const billTitle = p.bill_title as string | undefined;
) : ( const briefSummary = p.brief_summary as string | undefined;
<div className="divide-y divide-border"> return (
{history.map((event: NotificationEvent) => { <div className="flex items-start gap-3 py-3">
const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" }; <Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
const Icon = meta.icon; <div className="flex-1 min-w-0">
const payload = event.payload ?? {}; <div className="flex items-center gap-2 flex-wrap">
return ( <span className="text-xs font-medium">{meta.label}</span>
<div key={event.id} className="flex items-start gap-3 py-3"> {billLabel && (
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} /> <Link href={`/bills/${event.bill_id}`}
<div className="flex-1 min-w-0"> className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
<div className="flex items-center gap-2 flex-wrap"> {billLabel}
<span className="text-xs font-medium">{meta.label}</span> </Link>
{payload.bill_label && ( )}
<Link href={`/bills/${event.bill_id}`} <span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
{payload.bill_label}
</Link>
)}
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
{payload.bill_title && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{payload.bill_title}</p>
)}
{payload.brief_summary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{payload.brief_summary}</p>
)}
</div>
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
</div> </div>
); {billTitle && (
})} <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
</div> )}
)} {briefSummary && (
</section> <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
)}
</div>
{showDispatch && (
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
)}
</div>
);
};
return (
<>
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Recent Alerts
</h2>
<p className="text-xs text-muted-foreground mt-1">
Notifications for bills and members you directly follow. Last 50 events.
</p>
</div>
{historyLoading ? (
<p className="text-sm text-muted-foreground">Loading history</p>
) : directEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
No alerts yet. Follow some bills and check back after the next poll.
</p>
) : (
<div className="divide-y divide-border">
{directEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch />
))}
</div>
)}
</section>
{topicEvents.length > 0 && (
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4 text-muted-foreground" /> Based on your topic follows
</h2>
<p className="text-xs text-muted-foreground mt-1">
Bills matching topics you follow that have had recent activity.
These are delivered as notifications follow a bill directly to change its alert mode.
</p>
</div>
<div className="divide-y divide-border">
{topicEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch={false} />
))}
</div>
</section>
)}
</>
);
})()}
</div> </div>
); );
} }

View File

@@ -605,6 +605,7 @@ export default function SettingsPage() {
{ key: "govinfo", label: "GovInfo API" }, { key: "govinfo", label: "GovInfo API" },
{ key: "newsapi", label: "NewsAPI.org" }, { key: "newsapi", label: "NewsAPI.org" },
{ key: "google_news", label: "Google News RSS" }, { key: "google_news", label: "Google News RSS" },
{ key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" },
].map(({ key, label }) => { ].map(({ key, label }) => {
const r = healthData[key]; const r = healthData[key];
if (!r) return null; if (!r) return null;

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { ChevronDown, ChevronRight, Copy, Check, Loader2, PenLine } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import type { BriefSchema, CitedPoint } from "@/lib/types"; import { ChevronDown, ChevronRight, Copy, Check, ExternalLink, Loader2, Phone, PenLine } from "lucide-react";
import { billsAPI } from "@/lib/api"; import type { BriefSchema, CitedPoint, Member } from "@/lib/types";
import { billsAPI, membersAPI } from "@/lib/api";
import { useIsFollowing } from "@/lib/hooks/useFollows"; import { useIsFollowing } from "@/lib/hooks/useFollows";
interface DraftLetterPanelProps { interface DraftLetterPanelProps {
@@ -27,6 +28,15 @@ function chamberToRecipient(chamber?: string): "house" | "senate" {
return chamber?.toLowerCase() === "senate" ? "senate" : "house"; 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) { export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const existing = useIsFollowing("bill", billId); const existing = useIsFollowing("bill", billId);
@@ -53,6 +63,27 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); 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 keyPoints = brief.key_points ?? [];
const risks = brief.risks ?? []; const risks = brief.risks ?? [];
const allPoints = [ const allPoints = [
@@ -96,6 +127,7 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
selected_points: selectedPoints, selected_points: selectedPoints,
include_citations: includeCitations, include_citations: includeCitations,
zip_code: zipCode.trim() || undefined, zip_code: zipCode.trim() || undefined,
rep_name: repName,
}); });
setDraft(result.draft); setDraft(result.draft);
} catch (err: unknown) { } catch (err: unknown) {
@@ -253,20 +285,23 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
</div> </div>
{/* Options row */} {/* Options row */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-start gap-4">
<div className="space-y-0.5"> <div className="space-y-1">
<input <div className="flex items-center gap-2">
type="text" <input
value={zipCode} type="text"
onChange={(e) => setZipCode(e.target.value)} value={zipCode}
placeholder="ZIP code" onChange={(e) => setZipCode(e.target.value)}
maxLength={10} placeholder="ZIP code"
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground" 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 && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
<p className="text-[10px] text-muted-foreground">optional · not stored</p> <p className="text-[10px] text-muted-foreground">optional · not stored</p>
</div> </div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground mt-1.5">
<input <input
type="checkbox" type="checkbox"
checked={includeCitations} checked={includeCitations}
@@ -277,6 +312,72 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
</label> </label>
</div> </div>
{/* Rep lookup results */}
{isValidZip && !zipFetching && relevantReps.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Your {recipient === "senate" ? "senators" : "representative"}
</p>
{relevantReps.map((rep) => (
<div
key={rep.bioguide_id}
className="flex items-center gap-3 bg-muted/40 border border-border rounded-md px-3 py-2"
>
{rep.photo_url && (
<img
src={rep.photo_url}
alt={rep.name}
className="w-8 h-8 rounded-full object-cover shrink-0 border border-border"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">{formatRepName(rep)}</p>
{rep.party && (
<p className="text-[10px] text-muted-foreground">{rep.party} · {rep.state}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{rep.phone && (
<a
href={`tel:${rep.phone.replace(/\D/g, "")}`}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title="Office phone"
>
<Phone className="w-3 h-3" />
{rep.phone}
</a>
)}
{rep.official_url && (
<a
href={rep.official_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] text-primary hover:underline"
title="Contact form"
>
<ExternalLink className="w-3 h-3" />
Contact
</a>
)}
</div>
</div>
))}
{repName && (
<p className="text-[10px] text-muted-foreground">
Letter will be addressed to{" "}
{recipient === "senate" ? "Senator" : "Representative"} {repName}.
</p>
)}
</div>
)}
{isValidZip && !zipFetching && relevantReps.length === 0 && zipReps !== undefined && (
<p className="text-[10px] text-amber-500">
Could not find your {recipient === "senate" ? "senators" : "representative"} for that ZIP.
The letter will use a generic salutation.
</p>
)}
{/* Generate button */} {/* Generate button */}
<button <button
onClick={handleGenerate} onClick={handleGenerate}

View File

@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { TrendingUp, Calendar, User, FileText, FileClock, FileX } from "lucide-react"; import { TrendingUp, Calendar, User, FileText, FileClock, FileX, Tag } from "lucide-react";
import { Bill } from "@/lib/types"; import { Bill } from "@/lib/types";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils"; import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton"; import { FollowButton } from "./FollowButton";
@@ -28,12 +28,15 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
</span> </span>
)} )}
{tags.map((tag) => ( {tags.map((tag) => (
<span <Link
key={tag} key={tag}
className="text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground" href={`/bills?topic=${tag}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
> >
<Tag className="w-2.5 h-2.5" />
{tag} {tag}
</span> </Link>
))} ))}
</div> </div>

View File

@@ -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<HTMLDivElement>(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 (
<div className={cn("relative inline-flex items-center", className)} ref={ref}>
<button
type="button"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onClick={() => setVisible((v) => !v)}
aria-label="Help"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="w-3.5 h-3.5" />
</button>
{visible && (
<div className="absolute left-5 top-0 z-50 w-64 bg-popover border border-border rounded-md shadow-lg p-3 text-xs text-muted-foreground leading-relaxed">
{content}
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
Bookmark, Bookmark,
HelpCircle,
LayoutDashboard, LayoutDashboard,
FileText, FileText,
Users, Users,
@@ -28,6 +29,7 @@ const NAV = [
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true }, { href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
{ href: "/collections", label: "Collections", icon: Bookmark, 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: "/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 }, { href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
]; ];

View File

@@ -85,6 +85,7 @@ export const billsAPI = {
selected_points: string[]; selected_points: string[];
include_citations: boolean; include_citations: boolean;
zip_code?: string; zip_code?: string;
rep_name?: string;
}) => }) =>
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data), apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
}; };
@@ -132,6 +133,8 @@ export const membersAPI = {
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data), apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
get: (id: string) => get: (id: string) =>
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data), 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>) => getBills: (id: string, params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data), apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
getTrend: (id: string, days?: number) => getTrend: (id: string, days?: number) =>