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:
@@ -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<typeof notificationsAPI.updateSettings>[0]) =>
|
||||
notificationsAPI.updateSettings(data),
|
||||
@@ -497,59 +503,106 @@ export default function NotificationsPage() {
|
||||
</section>
|
||||
|
||||
{/* Notification History */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Last 50 notification events for your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const directEvents = history.filter((e: NotificationEvent) => {
|
||||
const src = (e.payload as Record<string, unknown>)?.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<string, unknown>)?.source as string | undefined;
|
||||
if (src === "topic_follow") return true;
|
||||
if (src) return false;
|
||||
return !directlyFollowedBillIds.has(e.bill_id);
|
||||
});
|
||||
|
||||
{historyLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading history…</p>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No events yet. Follow some bills and check back after the next poll.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{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 (
|
||||
<div key={event.id} className="flex items-start gap-3 py-3">
|
||||
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">{meta.label}</span>
|
||||
{payload.bill_label && (
|
||||
<Link href={`/bills/${event.bill_id}`}
|
||||
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>
|
||||
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<string, unknown>;
|
||||
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 (
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">{meta.label}</span>
|
||||
{billLabel && (
|
||||
<Link href={`/bills/${event.bill_id}`}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
|
||||
{billLabel}
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{billTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
|
||||
)}
|
||||
{briefSummary && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user