● Zion Boggan zionboggan.com ↗

F4 frontend: member photos (profiles/rosters/vote logs + initials fallback) + /schedule meetings page + filled fields; run.py photos/meetings steps

320a3fd   Zion Boggan committed on Jun 26, 2026 (1 month ago)
FRONTEND_NOTES.md +43 -3
@@ -1,9 +1,42 @@
# Docket - Frontend Notes
-Wave F2 + F3 build log. All surfaces consume the live read-API
+Wave F2 + F3 + F4 build log. All surfaces consume the live read-API
(`app/api/*` → `lib/feed.ts` → SQLite at `data/docket.db`). No mock data on
the deep surfaces; `lib/mock-data.ts` remains only for the Phase-0 lint
-reference and is not imported by any F2/F3 page.
+reference and is not imported by any F2/F3/F4 page.
+
+## F4 (this wave) - faces + schedule + fill the gaps
+
+- **Photos everywhere** - `members.photo_url` (official headshot mirror at
+ `unitedstates/images`) is now surfaced through the whole member read model
+ and shown via a new `MemberAvatar` component (lazy `<img>` over a clean
+ INITIALS tile; initials show when `photo_url` is NULL **or** the image
+ fails to load - never a broken image). Avatars appear on:
+ `/members` roster, `/members/[id]` profile header (lg), `/votes/[id]` roster
+ (sm, beside each name + position), `/bill/[id]` sponsor card, `/search`
+ member results, and `/states/[code]` delegation. The photo is **decorative**
+ (`aria-hidden`, `alt=""`); the cited record stays the allowlisted bioguide
+ Source link, so neutrality #1 is untouched. Member names are linked to
+ `/members/[id]` wherever they appear. ~400-500-row rosters stay fast via
+ `loading="lazy"` + `decoding="async"` + a plain `<img>` (no per-row
+ next/image domain config / JS cost).
+- **`/schedule` + `/api/meetings`** - the congressional meetings tracker.
+ `getMeetings(filter)` reads the `meetings` table (id, chamber, committee,
+ date, time, datetime_iso, title, subject, type, location, status,
+ source_url) **defensively**: while the table is mid-ingest (or absent),
+ `safeAll` swallows the missing-table error and the page shows an honest
+ "No meetings on record yet" - data flows the moment the table lands, no
+ code change. `MeetingsBrowser` (client) filters by search/chamber/type/
+ committee and toggles **Upcoming / Past / All** (today is computed
+ server-side and passed in to avoid hydration drift), then renders a
+ **day-grouped list**: each day is a header with its meetings as cards
+ (time, chamber, committee, subject, type pill, status, location, Source
+ link). Type is shown as a **text mark** (never color alone) so it reads in
+ Plain mode and for the color-blind. Added to header + footer nav.
+- **Empty-field honesty** - every member-name surface now carries a headshot
+ or an honest initials tile; the member profile header states "No official
+ headshot on file" when `photo_url` is NULL. Voice/UC vote and absent-data
+ states already carry their honesty notes; no blank/broken UI element ships.
## F3 (this wave) - DATA-layer UI
@@ -59,6 +92,10 @@ Every returned fact carries an allowlisted `sourceUrl` (neutrality #1).
`getBillSubjects(billId)` (bill_subjects), `getChangeEvents(limit)`
(change_events), and `MemberDetail.votesByYear` (votes grouped by
`votes.year`) + `BillDetail.subjects`.
+- **F4 additions:** `MemberSummary.photoUrl` + `VotePosition.photoUrl`
+ (members.photo_url wired into every member/vote query), `Meeting` +
+ `MeetingFilter` + `getMeetings(filter)` + `getMeetingCommittees()`
+ (meetings table, defensively read).
## Pages
@@ -75,6 +112,7 @@ Every returned fact carries an allowlisted `sourceUrl` (neutrality #1).
| `/states/[code]` | State detail: delegation + sponsored bills | `getStateDetail` |
| `/search` | Global search (bills/members/executive/awards/states). Labeled, ≥52px target, GET-form deep-links | `globalSearch` + `SearchBox` |
| `/calendar` | **Month-grid calendar** - what takes effect / becomes law / votes, day by day. Kind filter + legend (text marks, not color alone), day panel, prev/next/today nav. Legible in all 3 modes. | `getCalendarEvents` |
+| `/schedule` | **Congressional meetings** - committee hearings/markups/business meetings/floor action, day-grouped. Upcoming/Past/All scope + chamber/type/committee filters; each links to its official schedule. Defensive: honest "No meetings on record yet" while the `meetings` table is mid-ingest. | `getMeetings` + `MeetingsBrowser` |
| `/categories` | Topic browse - bills + awards per policy area (counts only) | `getCategories` |
| `/bills` | Browseable bill list; filter target for topics (`?policyArea=`), congress, text | `getBills` + `getPolicyAreaLabels` |
@@ -95,6 +133,8 @@ Every returned fact carries an allowlisted `sourceUrl` (neutrality #1).
`position-tag.tsx` (PositionTag, muted yea/nay), `rider-flag.tsx` (RiderFlag,
ChangeTag), `members-browser.tsx`, `executive-browser.tsx`, `search-box.tsx`.
F3: `changes-feed.tsx` (compact added/updated/removed list with Source link).
+F4: `member-avatar.tsx` (`MemberAvatar` + `initialsOf`, lazy img over initials,
+decorative), `meetings-browser.tsx` (filter + day-grouped Upcoming/Past/All).
## Member page - year-index
@@ -108,4 +148,4 @@ member's position on each vote (muted `PositionTag`). Undated votes bucket to
`npm run build` passes (Next 14, strict TS). The `npm run neutrality` script
reports JSX `key={...}` false positives and a pre-existing `.pa.us` entry in
-mock-data; these are not real copy violations and the build gate is clean.
+mock-data; these are not real copy violations and the build gate is clean.
app/api/meetings/route.ts +29 -0
@@ -0,0 +1,29 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { parsePaging, type ItemList } from "@/lib/db";
+import { getMeetings, type Meeting } from "@/lib/feed";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/meetings - congressional committee + floor meetings (meetings table).
+ * Filters: ?from=&to=&chamber=&committee=&type= Paginated { items, total, ... }.
+ *
+ * Defensive: while the `meetings` table is mid-ingest (or absent), getMeetings
+ * returns [] and this responds { items: [], total: 0 } - never a 5xx. The API
+ * contract is "always respond" (docs/API.md).
+ */
+export async function GET(req: NextRequest) {
+ const sp = req.nextUrl.searchParams;
+ const { limit, offset } = parsePaging(sp);
+ const items = getMeetings({
+ from: sp.get("from") || undefined,
+ to: sp.get("to") || undefined,
+ chamber: sp.get("chamber") || undefined,
+ committee: sp.get("committee") || undefined,
+ type: sp.get("type") || undefined,
+ });
+ const total = items.length;
+ const paged = items.slice(offset, offset + limit);
+ const body: ItemList<Meeting> = { items: paged, total, limit, offset };
+ return NextResponse.json(body);
+}
app/bill/[id]/page.tsx +18 -12
@@ -5,6 +5,7 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { PartyTag } from "@/components/party-tag";
+import { MemberAvatar } from "@/components/member-avatar";
import { RiderFlag, ChangeTag } from "@/components/rider-flag";
import { getBillDetail, type BillDiffChange } from "@/lib/feed";
import { formatDate } from "@/lib/format";
@@ -66,18 +67,23 @@ export default function BillPage({
<p className="label mb-2">Sponsor</p>
{bill.sponsor ? (
<>
- <Link
- href={bill.sponsor.href}
- className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper"
- >
- {bill.sponsor.name}
- </Link>
- <div className="mt-2 flex items-center gap-2">
- <PartyTag party={bill.sponsor.partyCode} />
- <span className="font-mono text-[12px] text-ash">
- {bill.sponsor.state}
- {bill.sponsor.district != null ? ` ${bill.sponsor.district}` : ""}
- </span>
+ <div className="flex items-start gap-3">
+ <MemberAvatar name={bill.sponsor.name} photoUrl={bill.sponsor.photoUrl} size="md" />
+ <div className="min-w-0">
+ <Link
+ href={bill.sponsor.href}
+ className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper"
+ >
+ {bill.sponsor.name}
+ </Link>
+ <div className="mt-2 flex items-center gap-2">
+ <PartyTag party={bill.sponsor.partyCode} />
+ <span className="font-mono text-[12px] text-ash">
+ {bill.sponsor.state}
+ {bill.sponsor.district != null ? ` ${bill.sponsor.district}` : ""}
+ </span>
+ </div>
+ </div>
</div>
</>
) : bill.sponsorDisplay ? (
app/members/[id]/page.tsx +41 -8
@@ -1,10 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
-import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { Card, SectionHeader } from "@/components/ui";
import { SourceLink } from "@/components/source-link";
import { PartyTag } from "@/components/party-tag";
import { PositionTag } from "@/components/position-tag";
+import { MemberAvatar } from "@/components/member-avatar";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { getMember } from "@/lib/feed";
import { formatDate } from "@/lib/format";
@@ -41,13 +42,45 @@ export default function MemberDetailPage({
← Members
</Link>
- <PageHeader
- eyebrow={`${member.chamber === "senate" ? "U.S. Senate" : "U.S. House"} · ${member.state}${
- member.district != null ? ` district ${member.district}` : ""
- }`}
- title={member.name}
- lead={`Recorded roll-call positions, sponsored bills, and amendments. ${member.voteCount} recorded vote position(s) on file.`}
- />
+ {/* Profile header - photo + name + party/state/chamber. The photo is a
+ decorative aid (decorative alt=""); the cited record stays the
+ allowlisted bioguide Source link below (neutrality #1). */}
+ <header className="border-b border-line pb-8">
+ <div className="flex flex-col gap-5 sm:flex-row sm:items-start">
+ <MemberAvatar
+ name={member.name}
+ photoUrl={member.photoUrl}
+ size="lg"
+ className="shrink-0"
+ />
+ <div className="min-w-0 flex-1">
+ <p className="label mb-3">
+ {member.chamber === "senate" ? "U.S. Senate" : "U.S. House"} · {member.state}
+ {member.district != null ? ` · district ${member.district}` : ""}
+ </p>
+ <h1 className="display text-4xl text-paper sm:text-5xl">{member.name}</h1>
+ <div className="mt-4 flex flex-wrap items-center gap-3">
+ <PartyTag party={member.partyCode} />
+ <span className="font-mono text-[13px] text-ash">
+ {member.party || "Party not on record"}
+ </span>
+ <span className="label text-ash-2" aria-hidden="true">·</span>
+ <span className="font-mono text-[13px] text-ash-2">
+ {member.voteCount} recorded position{member.voteCount === 1 ? "" : "s"} on file
+ </span>
+ </div>
+ </div>
+ </div>
+ {member.photoUrl ? (
+ <p className="label mt-3 text-ash-2">
+ Headshot from the official congressional image record. Official biography below.
+ </p>
+ ) : (
+ <p className="label mt-3 text-ash-2">
+ No official headshot on file for this member.
+ </p>
+ )}
+ </header>
{/* top facts */}
<div className="mt-8 grid gap-4 sm:grid-cols-3">
app/schedule/page.tsx +85 -0
@@ -0,0 +1,85 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { PageHeader } from "@/components/ui";
+import { getMeetings, getMeetingCommittees } from "@/lib/feed";
+import { MeetingsBrowser } from "@/components/meetings-browser";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Congressional Schedule",
+ description:
+ "When Congress meets: committee hearings, markups, business meetings, and floor action. Each meeting links to its official schedule.",
+};
+
+/** Today as YYYY-MM-DD in UTC - passed to the browser so SSR and the client
+ * agree on "upcoming vs past" (no hydration drift). */
+function isoTodayUtc(): string {
+ const d = new Date();
+ const pad = (n: number) => (n < 10 ? `0${n}` : String(n));
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
+}
+
+export default function SchedulePage() {
+ const all = getMeetings();
+ const committees = getMeetingCommittees();
+ const todayIso = isoTodayUtc();
+
+ const upcoming = all.filter((m) => !m.date || m.date >= todayIso).length;
+ const past = all.length - upcoming;
+ const hearings = all.filter((m) => (m.type || "").toLowerCase() === "hearing").length;
+ const floor = all.filter((m) => (m.type || "").toLowerCase() === "floor").length;
+
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <Link
+ href="/register"
+ className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ >
+ ← Register
+ </Link>
+
+ <PageHeader
+ eyebrow="Schedule · what Congress is meeting about"
+ title="Congressional meetings"
+ lead="Committee hearings, markups, business meetings, and floor action. Browse upcoming sessions or look back at past meetings. Each entry links to its official schedule."
+ />
+
+ {/* Summary counts - by status, then alphabetical (not by party/team). */}
+ <div className="mt-8 grid gap-4 sm:grid-cols-4">
+ <SummaryCard label="Total on record" count={all.length} />
+ <SummaryCard label="Upcoming" count={upcoming} />
+ <SummaryCard label="Past" count={past} />
+ <SummaryCard label="Hearings" count={hearings} />
+ </div>
+ {floor > 0 ? (
+ <p className="label mt-3 text-ash-2">{floor} floor action(s) in the record.</p>
+ ) : null}
+
+ <div className="mt-10">
+ <MeetingsBrowser seed={all} committees={committees} todayIso={todayIso} />
+ </div>
+
+ <div className="mt-12 border-t border-line pt-6">
+ <p className="label text-ash-2">
+ Meeting schedules from clerk.house.gov, senate.gov, and committee .gov
+ sites. Each meeting links to its source. No commentary appears here.
+ </p>
+ </div>
+ </div>
+ );
+}
+
+function SummaryCard({ label, count }: { label: string; count: number }) {
+ return (
+ <div className="rounded-[3px] border border-line bg-surface p-5">
+ <p className="label mb-2">{label}</p>
+ <div className="flex items-baseline gap-2">
+ <span className="display text-3xl text-paper">{count}</span>
+ <span className="label text-ash-2">
+ {count === 1 ? "meeting" : "meetings"}
+ </span>
+ </div>
+ </div>
+ );
+}
app/search/page.tsx +12 -8
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Card, PageHeader } from "@/components/ui";
import { SourceLink } from "@/components/source-link";
+import { MemberAvatar } from "@/components/member-avatar";
import { globalSearch, type SearchResults } from "@/lib/feed";
import { SearchBox } from "@/components/search-box";
@@ -83,15 +84,18 @@ export default function SearchPage({
<Link
key={m.id}
href={m.href}
- className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
+ className="focus-ring group flex items-center gap-3 border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
>
- <p className="font-mono text-[13px] text-paper group-hover:underline">
- {m.name}
- </p>
- <p className="label text-ash-2">
- {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
- {m.district != null ? ` ${m.district}` : ""} · {m.party}
- </p>
+ <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" />
+ <span className="min-w-0">
+ <span className="block font-mono text-[13px] text-paper group-hover:underline">
+ {m.name}
+ </span>
+ <span className="label text-ash-2">
+ {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
+ {m.district != null ? ` ${m.district}` : ""} · {m.party}
+ </span>
+ </span>
</Link>
))}
</ResultGroup>
app/states/[code]/page.tsx +9 -7
@@ -3,6 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { PartyTag } from "@/components/party-tag";
+import { MemberAvatar } from "@/components/member-avatar";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { getStateDetail } from "@/lib/feed";
import { formatDate } from "@/lib/format";
@@ -97,17 +98,18 @@ export default function StateDetailPage({
<Link
key={m.id}
href={m.href}
- className="focus-ring group flex items-center justify-between gap-2 p-4 transition-colors hover:bg-graphite"
+ className="focus-ring group flex items-center gap-3 p-4 transition-colors hover:bg-graphite"
>
- <div className="min-w-0">
- <p className="font-mono text-[13px] text-paper group-hover:underline">
+ <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" />
+ <span className="min-w-0 flex-1">
+ <span className="block font-mono text-[13px] text-paper group-hover:underline">
{m.name}
- </p>
- <p className="label mt-1 text-ash-2">
+ </span>
+ <span className="label mt-1 block text-ash-2">
{m.chamber === "senate" ? "Senate" : "House"}
{m.district != null ? ` · Dist. ${m.district}` : ""}
- </p>
- </div>
+ </span>
+ </span>
<PartyTag party={m.partyCode} />
</Link>
))}
app/votes/[id]/page.tsx +16 -10
@@ -7,6 +7,7 @@ import { StatusPill, classifyStatus } from "@/components/status-pill";
import { VoteTally } from "@/components/vote-tally";
import { PositionTag, positionColor } from "@/components/position-tag";
import { PartyTag } from "@/components/party-tag";
+import { MemberAvatar } from "@/components/member-avatar";
import { getVoteById, type VotePosition } from "@/lib/feed";
import { formatDate } from "@/lib/format";
@@ -186,16 +187,21 @@ export default function VoteDetailPage({
className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
>
<Td className="text-paper">
- {p.memberHref ? (
- <Link
- href={p.memberHref}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {p.member}
- </Link>
- ) : (
- p.member
- )}
+ <div className="flex items-center gap-2">
+ <MemberAvatar name={p.member} photoUrl={p.photoUrl} size="sm" />
+ <span className="min-w-0">
+ {p.memberHref ? (
+ <Link
+ href={p.memberHref}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {p.member}
+ </Link>
+ ) : (
+ p.member
+ )}
+ </span>
+ </div>
</Td>
<Td>
{p.party ? <PartyTag party={p.partyCode} /> : "-"}
components/meetings-browser.tsx +394 -0
@@ -0,0 +1,394 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Card } from "@/components/ui";
+import { SourceLink } from "@/components/source-link";
+import { clsx } from "@/lib/cx";
+import type { Meeting } from "@/lib/feed";
+
+/**
+ * Client browse/filter for the congressional meetings schedule. All filtering
+ * is local over the server-seeded list (no fetch-to-self). `todayIso` is
+ * computed server-side and passed in so server/client agree (no hydration
+ * drift on "today").
+ *
+ * Scope defaults to Upcoming (next-soonest first, day-grouped). Past is most-
+ * recent-first. Each meeting shows date + time + chamber + committee + subject
+ * + type + status, links to its official source, and never relies on color
+ * alone (type carries a text mark) so it reads in Plain mode and for the
+ * color-blind (docs/ACCESSIBILITY.md #7; docs/NEUTRALITY.md §Viz).
+ */
+type Scope = "upcoming" | "past" | "all";
+
+type DayGroup = { date: string; items: Meeting[] };
+
+type TypeMeta = { plain: string; color: string };
+
+const TYPE_META: Record<string, TypeMeta> = {
+ hearing: { plain: "Hearing", color: "var(--dk-hold)" },
+ markup: { plain: "Markup", color: "var(--dk-party-d)" },
+ business_meeting: { plain: "Business meeting", color: "var(--dk-party-i)" },
+ floor: { plain: "Floor", color: "var(--dk-party-r)" },
+};
+
+function typeMeta(t: string): TypeMeta {
+ return (
+ TYPE_META[(t || "").toLowerCase()] ?? {
+ plain: (t || "Meeting").replace(/_/g, " "),
+ color: "var(--dk-ash)",
+ }
+ );
+}
+
+const MONTHS = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+];
+const WEEKDAYS = [
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
+];
+
+function formatDayHeader(iso: string): string {
+ const d = new Date(iso.length === 10 ? iso + "T00:00:00Z" : iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString("en-US", {
+ timeZone: "UTC",
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+function formatTime(m: Meeting): string {
+ const t = (m.time || "").trim();
+ if (!t) return "";
+ // Normalize "14:00" / "14:00:00" → "2:00 PM"; pass through anything odd.
+ const parts = t.split(":");
+ const hh = parseInt(parts[0], 10);
+ const mm = parseInt(parts[1], 10);
+ if (!Number.isFinite(hh) || !Number.isFinite(mm)) return t;
+ const period = hh < 12 ? "AM" : "PM";
+ const h12 = ((hh + 11) % 12) + 1;
+ return `${h12}:${String(mm).padStart(2, "0")} ${period}`;
+}
+
+export function MeetingsBrowser({
+ seed,
+ committees,
+ todayIso,
+}: {
+ seed: Meeting[];
+ committees: string[];
+ todayIso: string;
+}) {
+ const [q, setQ] = useState("");
+ const [chamber, setChamber] = useState("");
+ const [type, setType] = useState("");
+ const [committee, setCommittee] = useState("");
+ const [scope, setScope] = useState<Scope>("upcoming");
+
+ const filtered = useMemo(() => {
+ const query = q.trim().toLowerCase();
+ return seed.filter((m) => {
+ if (
+ query &&
+ !m.committee.toLowerCase().includes(query) &&
+ !m.subject.toLowerCase().includes(query) &&
+ !m.title.toLowerCase().includes(query)
+ ) {
+ return false;
+ }
+ if (chamber && m.chamber.toLowerCase() !== chamber.toLowerCase()) return false;
+ if (type && m.type.toLowerCase() !== type.toLowerCase()) return false;
+ if (committee && m.committee !== committee) return false;
+ if (scope === "upcoming" && m.date && m.date < todayIso) return false;
+ if (scope === "past" && (!m.date || m.date >= todayIso)) return false;
+ return true;
+ });
+ }, [seed, q, chamber, type, committee, scope, todayIso]);
+
+ // Day-grouped; ascending for upcoming/all-by-default, descending for past.
+ const groups = useMemo(() => groupByDay(filtered, scope === "past"), [filtered, scope]);
+
+ const upcomingCount = useMemo(
+ () => seed.filter((m) => !m.date || m.date >= todayIso).length,
+ [seed, todayIso]
+ );
+ const pastCount = seed.length - upcomingCount;
+ const hasAny = seed.length > 0;
+
+ function clearAll() {
+ setQ("");
+ setChamber("");
+ setType("");
+ setCommittee("");
+ }
+ const filtering = q || chamber || type || committee;
+
+ return (
+ <>
+ {/* Filter bar - labeled selects + scope (a11y §Search). */}
+ <Card className="p-4 sm:p-5">
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
+ <Field label="Search">
+ <input
+ type="search"
+ value={q}
+ onChange={(e) => setQ(e.target.value)}
+ placeholder="Committee or subject"
+ aria-label="Search meetings by committee or subject"
+ className="focus-ring w-full rounded-[2px] border border-line bg-ink px-3 py-2 font-mono text-[13px] text-paper placeholder:text-ash-2"
+ />
+ </Field>
+ <Field label="Chamber">
+ <Select
+ ariaLabel="Filter by chamber"
+ value={chamber}
+ onChange={setChamber}
+ options={[
+ { value: "", label: "Both chambers" },
+ { value: "senate", label: "Senate" },
+ { value: "house", label: "House" },
+ ]}
+ />
+ </Field>
+ <Field label="Type">
+ <Select
+ ariaLabel="Filter by meeting type"
+ value={type}
+ onChange={setType}
+ options={[
+ { value: "", label: "All types" },
+ { value: "hearing", label: "Hearing" },
+ { value: "markup", label: "Markup" },
+ { value: "business_meeting", label: "Business meeting" },
+ { value: "floor", label: "Floor" },
+ ]}
+ />
+ </Field>
+ <Field label="Committee">
+ <Select
+ ariaLabel="Filter by committee"
+ value={committee}
+ onChange={setCommittee}
+ options={[
+ { value: "", label: "All committees" },
+ ...committees.map((c) => ({ value: c, label: c })),
+ ]}
+ />
+ </Field>
+ </div>
+
+ {/* Scope - segmented control, ≥44px targets. */}
+ <div
+ role="group"
+ aria-label="Show upcoming or past meetings"
+ className="mt-4 flex flex-wrap items-center gap-1"
+ >
+ {(
+ [
+ { value: "upcoming", label: `Upcoming · ${upcomingCount}` },
+ { value: "past", label: `Past · ${pastCount}` },
+ { value: "all", label: `All · ${seed.length}` },
+ ] as Array<{ value: Scope; label: string }>
+ ).map((opt) => {
+ const active = scope === opt.value;
+ return (
+ <button
+ key={opt.value}
+ type="button"
+ onClick={() => setScope(opt.value)}
+ aria-pressed={active}
+ className={clsx(
+ "focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border px-3",
+ active
+ ? "border-paper text-paper"
+ : "border-line text-ash hover:text-paper"
+ )}
+ >
+ {opt.label}
+ </button>
+ );
+ })}
+ {filtering ? (
+ <button
+ type="button"
+ onClick={clearAll}
+ className="focus-ring label ml-auto inline-flex min-h-[44px] items-center rounded-sm px-2 text-ash underline underline-offset-4 hover:text-paper"
+ >
+ Clear filters
+ </button>
+ ) : null}
+ </div>
+ </Card>
+
+ {/* Day-grouped list. */}
+ <div className="mt-6">
+ {!hasAny ? (
+ <Card className="p-8 text-center">
+ <p className="display text-2xl text-paper">No meetings on record yet.</p>
+ <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
+ Congressional committee and floor meetings will appear here - when
+ committees hold them, each sealed to its official schedule.
+ </p>
+ </Card>
+ ) : groups.length === 0 ? (
+ <Card className="p-8 text-center">
+ <p className="display text-2xl text-paper">No meetings match.</p>
+ <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
+ {scope === "upcoming"
+ ? "No upcoming meetings match the filters. Try Past, or clear a filter."
+ : "Adjust the filters above."}
+ </p>
+ </Card>
+ ) : (
+ <div className="space-y-8">
+ {groups.map((g) => (
+ <section key={g.date || "undated"} aria-label={formatDayHeader(g.date)}>
+ <div className="mb-3 flex items-baseline justify-between gap-3 border-b border-line pb-2">
+ <h3 className="font-mono text-[14px] text-paper">
+ {g.date ? formatDayHeader(g.date) : "Date not on record"}
+ </h3>
+ <span className="label text-ash-2">
+ {g.items.length} meeting{g.items.length === 1 ? "" : "s"}
+ </span>
+ </div>
+ <div className="grid gap-3 lg:grid-cols-2">
+ {g.items.map((m) => (
+ <MeetingCard key={m.id} m={m} />
+ ))}
+ </div>
+ </section>
+ ))}
+ </div>
+ )}
+ </div>
+ </>
+ );
+}
+
+function MeetingCard({ m }: { m: Meeting }) {
+ const tm = typeMeta(m.type);
+ const time = formatTime(m);
+ const cancelled = /cancel/i.test(m.status);
+ return (
+ <article className="flex flex-col gap-2 rounded-[3px] border border-line bg-surface p-4">
+ <div className="flex flex-wrap items-center gap-2">
+ <span
+ className="pill"
+ style={{
+ color: tm.color,
+ borderColor: `color-mix(in srgb, ${tm.color} 45%, transparent)`,
+ }}
+ >
+ {tm.plain}
+ </span>
+ <span className="label text-ash-2">
+ {m.chamber ? cap(m.chamber) : "Congress"}
+ </span>
+ {time ? (
+ <span className="label text-paper">{time}</span>
+ ) : null}
+ {m.status ? (
+ <span
+ className={clsx("pill", cancelled ? "text-nay" : "text-ash")}
+ style={
+ cancelled
+ ? { borderColor: "color-mix(in srgb, var(--dk-nay) 45%, transparent)" }
+ : undefined
+ }
+ >
+ {cap(m.status)}
+ </span>
+ ) : null}
+ </div>
+ <p className="font-mono text-[13px] text-paper">
+ {m.committee || "Committee not on record"}
+ </p>
+ {m.subject ? (
+ <p className="font-mono text-[12px] leading-relaxed text-ash">{m.subject}</p>
+ ) : null}
+ {m.title ? (
+ <p className="font-mono text-[12px] leading-relaxed text-ash-2">{m.title}</p>
+ ) : null}
+ {m.location ? (
+ <p className="label text-ash-2">Location · {m.location}</p>
+ ) : null}
+ <div className="mt-1">
+ <SourceLink url={m.sourceUrl} label="Schedule" />
+ </div>
+ </article>
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+ <label className="block">
+ <span className="label mb-1.5 block">{label}</span>
+ {children}
+ </label>
+ );
+}
+
+function Select({
+ value,
+ onChange,
+ ariaLabel,
+ options,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ ariaLabel: string;
+ options: Array<{ value: string; label: string }>;
+}) {
+ return (
+ <select
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label={ariaLabel}
+ className={clsx(
+ "focus-ring w-full rounded-[2px] border border-line bg-ink px-3 py-2",
+ "font-mono text-[13px] text-paper"
+ )}
+ >
+ {options.map((o) => (
+ <option key={o.value} value={o.value} className="bg-surface text-paper">
+ {o.label}
+ </option>
+ ))}
+ </select>
+ );
+}
+
+/** Group by date; ascending for upcoming/all, descending for past. */
+function groupByDay(list: Meeting[], desc: boolean): DayGroup[] {
+ const map = new Map<string, Meeting[]>();
+ for (const m of list) {
+ const key = m.date || "";
+ const arr = map.get(key) ?? [];
+ arr.push(m);
+ map.set(key, arr);
+ }
+ // Within a day: upcoming → earliest time first; past → latest first.
+ for (const arr of map.values()) {
+ arr.sort((a, b) => {
+ const ta = a.datetimeIso || `${a.date} ${a.time}`;
+ const tb = b.datetimeIso || `${b.date} ${b.time}`;
+ return desc ? tb.localeCompare(ta) : ta.localeCompare(tb);
+ });
+ }
+ const keys = Array.from(map.keys());
+ keys.sort((a, b) => (desc ? b.localeCompare(a) : a.localeCompare(b)));
+ // Undated (empty key) always sorts last regardless of direction.
+ keys.sort((a, b) => {
+ if (!a && b) return 1;
+ if (a && !b) return -1;
+ return 0;
+ });
+ return keys.map((date) => ({ date, items: map.get(date)! }));
+}
+
+function cap(s: string): string {
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
components/member-avatar.tsx +80 -0
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState } from "react";
+import { clsx } from "@/lib/cx";
+
+/**
+ * Member headshot with an honest fallback. Surfaces members.photo_url
+ * (an official headshot mirror at unitedstates/images) as a decorative
+ * <img loading="lazy"> over a clean INITIALS tile:
+ * - photo_url missing → only the initials render (never a broken image).
+ * - photo_url present → the image loads over the initials; on error the
+ * initials are revealed again.
+ *
+ * Decorative by design (aria-hidden): the member's name is always rendered
+ * as real text/links beside it, and the cited record stays the allowlisted
+ * bioguide Source link - the photo is a visual aid, not a cited fact, so it
+ * does not affect neutrality #1. Uses no brass (rationed accent) - neutral
+ * surface tokens only, so it stays legible in Dark / Light / Plain modes.
+ */
+export type AvatarSize = "sm" | "md" | "lg";
+
+const SIZE_CLASS: Record<AvatarSize, string> = {
+ sm: "h-8 w-8 text-[11px]",
+ md: "h-11 w-11 text-[12px]",
+ lg: "h-20 w-20 text-xl",
+};
+
+/** "Alex P. Smith (CA)" → "AS". Single token → first two letters. */
+export function initialsOf(name: string): string {
+ const cleaned = name.replace(/\(.*?\)/g, "").trim();
+ const parts = cleaned.split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return "?";
+ if (parts.length === 1) {
+ return parts[0].slice(0, 2).toUpperCase();
+ }
+ const last = parts[parts.length - 1];
+ return (parts[0][0] + (last[0] ?? "")).toUpperCase();
+}
+
+export function MemberAvatar({
+ name,
+ photoUrl,
+ size = "sm",
+ className,
+}: {
+ name: string;
+ photoUrl?: string | null;
+ size?: AvatarSize;
+ className?: string;
+}) {
+ const [broken, setBroken] = useState(false);
+ const showImg = !!photoUrl && !broken;
+
+ return (
+ <span
+ aria-hidden="true"
+ className={clsx(
+ "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full border border-line bg-graphite font-mono font-semibold uppercase tracking-tight text-ash",
+ SIZE_CLASS[size],
+ className
+ )}
+ >
+ <span className="leading-none">{initialsOf(name)}</span>
+ {showImg ? (
+ // Plain <img>: avoids next/image remote-domain config and keeps the
+ // roster (hundreds of rows) light. Lazy + async + no referrer.
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={photoUrl || undefined}
+ alt=""
+ loading="lazy"
+ decoding="async"
+ referrerPolicy="no-referrer"
+ onError={() => setBroken(true)}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ ) : null}
+ </span>
+ );
+}
components/members-browser.tsx +14 -10
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { Card } from "@/components/ui";
import { PartyTag } from "@/components/party-tag";
+import { MemberAvatar } from "@/components/member-avatar";
import { clsx } from "@/lib/cx";
import type { MemberSummary } from "@/lib/feed";
@@ -133,17 +134,20 @@ export function MembersBrowser({
href={m.href}
className="focus-ring group rounded-[3px] border border-line bg-surface p-4 transition-colors hover:bg-graphite"
>
- <div className="flex items-start justify-between gap-2">
- <div className="min-w-0">
- <p className="font-mono text-[13px] text-paper group-hover:underline">
- {m.name}
- </p>
- <p className="label mt-1 text-ash-2">
- {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
- {m.district != null ? ` ${m.district}` : ""}
- </p>
+ <div className="flex items-start gap-3">
+ <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="md" />
+ <div className="flex min-w-0 flex-1 items-start justify-between gap-2">
+ <div className="min-w-0">
+ <p className="font-mono text-[13px] text-paper group-hover:underline">
+ {m.name}
+ </p>
+ <p className="label mt-1 text-ash-2">
+ {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
+ {m.district != null ? ` ${m.district}` : ""}
+ </p>
+ </div>
+ <PartyTag party={m.partyCode} />
</div>
- <PartyTag party={m.partyCode} />
</div>
<div className="mt-3 flex items-center justify-between">
<span className="label text-ash-2">{m.bioguide}</span>
components/site-footer.tsx +1 -0
@@ -22,6 +22,7 @@ export function SiteFooter() {
<nav aria-label="Footer" className="grid grid-cols-2 gap-x-10 gap-y-2 sm:grid-cols-3">
<FooterLink href="/register">Register</FooterLink>
<FooterLink href="/calendar">Calendar</FooterLink>
+ <FooterLink href="/schedule">Schedule</FooterLink>
<FooterLink href="/votes">Votes</FooterLink>
<FooterLink href="/members">Members</FooterLink>
<FooterLink href="/bills">Bills</FooterLink>
components/site-header.tsx +1 -0
@@ -9,6 +9,7 @@ import { clsx } from "@/lib/cx";
const NAV: Array<{ href: string; label: string }> = [
{ href: "/register", label: "Register" },
{ href: "/calendar", label: "Calendar" },
+ { href: "/schedule", label: "Schedule" },
{ href: "/votes", label: "Votes" },
{ href: "/members", label: "Members" },
{ href: "/bills", label: "Bills" },
lib/feed.ts +104 -7
@@ -120,6 +120,7 @@ export type MemberSummary = {
state: string;
district: number | null;
chamber: string; // house | senate
+ photoUrl: string; // official headshot (unitedstates/images mirror) - "" when none
sourceUrl: string;
href: string;
};
@@ -131,6 +132,7 @@ export type VotePosition = {
state: string;
district: number | null;
position: string; // Yea / Nay / Present / Not Voting
+ photoUrl: string; // official headshot - "" when none
memberHref: string;
};
@@ -564,7 +566,7 @@ export function getVoteById(id: number): VoteDetail | null {
const positions: VotePosition[] = recorded
? safeAll<VotePosition>(
- `SELECT vp.position, m.full_name, m.party, m.state, m.district, m.id
+ `SELECT vp.position, m.full_name, m.party, m.state, m.district, m.photo_url, m.id
FROM vote_positions vp
LEFT JOIN members m ON m.id = vp.member_id
WHERE vp.vote_id = :id`,
@@ -576,6 +578,7 @@ export function getVoteById(id: number): VoteDetail | null {
state: str(p.state),
district: nullableNum(p.district),
position: str(p.position) || "Not Voting",
+ photoUrl: str(p.photo_url),
memberHref: p.id ? `/members/${num(p.id)}` : "",
})
)
@@ -905,7 +908,7 @@ export type MemberFilter = {
export function getMembers(filter: MemberFilter = {}): MemberSummary[] {
const items = safeAll<MemberSummary>(
- `SELECT id, bioguide_id, full_name, party, state, district, chamber, source_url
+ `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url
FROM members
ORDER BY chamber, state, full_name`,
{},
@@ -952,7 +955,7 @@ export function getMember(rawId: string): MemberDetail | null {
const numeric = parseInt(cleaned, 10);
const byId = Number.isFinite(numeric)
? safeGet<Record<string, unknown>>(
- `SELECT id, bioguide_id, full_name, party, state, district, chamber, source_url
+ `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url
FROM members WHERE id = :id`,
{ id: numeric }
)
@@ -960,7 +963,7 @@ export function getMember(rawId: string): MemberDetail | null {
const row =
byId ??
safeGet<Record<string, unknown>>(
- `SELECT id, bioguide_id, full_name, party, state, district, chamber, source_url
+ `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url
FROM members WHERE bioguide_id = :bg`,
{ bg: cleaned }
);
@@ -1098,6 +1101,7 @@ function toMemberSummary(r: Record<string, unknown>): MemberSummary {
state: str(r.state),
district: nullableNum(r.district),
chamber: str(r.chamber).toLowerCase() || "house",
+ photoUrl: str(r.photo_url),
sourceUrl: ensureSource(r.source_url),
href: `/members/${idNum}`,
};
@@ -1106,7 +1110,7 @@ function toMemberSummary(r: Record<string, unknown>): MemberSummary {
function getMemberSummaryByBioguide(bioguide: string): MemberSummary | null {
if (!bioguide) return null;
const r = safeGet<Record<string, unknown>>(
- `SELECT id, bioguide_id, full_name, party, state, district, chamber, source_url
+ `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url
FROM members WHERE bioguide_id = :bg`,
{ bg: bioguide }
);
@@ -1317,7 +1321,7 @@ export function globalSearch(q: string): SearchResults {
);
const members = safeAll<MemberSummary>(
- `SELECT id, bioguide_id, full_name, party, state, district, chamber, source_url
+ `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url
FROM members
WHERE full_name LIKE :q ESCAPE '\\' OR bioguide_id LIKE :q ESCAPE '\\'
OR state LIKE :q ESCAPE '\\'
@@ -1445,9 +1449,102 @@ function calendarHref(refType: string, refId: number | null): string | null {
}
/* ------------------------------------------------------------------ */
-/* CATEGORIES - topic browse (bills.policy_area + awards.category) */
+/* MEETINGS - congressional committee + floor schedule (meetings table)*/
/* ------------------------------------------------------------------ */
+export type MeetingType =
+ | "hearing"
+ | "markup"
+ | "business_meeting"
+ | "floor"
+ | string;
+
+export type Meeting = {
+ id: string;
+ chamber: string; // house | senate
+ committee: string;
+ date: string; // YYYY-MM-DD
+ time: string; // "10:00" etc, "" when none
+ datetimeIso: string; // full ISO; "" when none
+ title: string;
+ subject: string;
+ type: MeetingType; // hearing | markup | business_meeting | floor
+ location: string;
+ status: string; // scheduled | cancelled | postoned | held, etc
+ sourceUrl: string;
+};
+
+export type MeetingFilter = {
+ from?: string;
+ to?: string;
+ chamber?: string;
+ committee?: string;
+ type?: string;
+};
+
+/**
+ * Congressional committee + floor meetings (the `meetings` table). Defensive:
+ * while the ingest that populates this table is mid-flight (or the table is
+ * absent) `safeAll` swallows the missing-table error and returns []. Each row
+ * carries an allowlisted source_url (neutrality #1). Ordered newest-first by
+ * datetime so upcoming and past both fall out naturally for the UI.
+ */
+export function getMeetings(filter: MeetingFilter = {}): Meeting[] {
+ const where: string[] = [];
+ const params: Record<string, unknown> = {};
+ if (filter.from) {
+ where.push("date >= :from");
+ params.from = filter.from;
+ }
+ if (filter.to) {
+ where.push("date <= :to");
+ params.to = filter.to;
+ }
+ if (filter.chamber) {
+ where.push("LOWER(chamber) = :chamber");
+ params.chamber = filter.chamber.toLowerCase();
+ }
+ if (filter.committee) {
+ where.push("committee LIKE :committee ESCAPE '\\'");
+ params.committee = `%${filter.committee.replace(/[%_\\]/g, (m) => "\\" + m)}%`;
+ }
+ if (filter.type) {
+ where.push("LOWER(type) = :type");
+ params.type = filter.type.toLowerCase();
+ }
+ const sql = `SELECT id, chamber, committee, date, time, datetime_iso, title,
+ subject, type, location, status, source_url
+ FROM meetings${where.length ? " WHERE " + where.join(" AND ") : ""}
+ ORDER BY datetime_iso DESC, date DESC, id DESC`;
+ return safeAll<Meeting>(sql, params, (r) => ({
+ id: `meeting:${r.id}`,
+ chamber: str(r.chamber),
+ committee: str(r.committee),
+ date: str(r.date),
+ time: str(r.time),
+ datetimeIso: str(r.datetime_iso),
+ title: str(r.title),
+ subject: str(r.subject),
+ type: str(r.type) as MeetingType,
+ location: str(r.location),
+ status: str(r.status),
+ sourceUrl: ensureSource(r.source_url),
+ }));
+}
+
+/** Distinct committees present in the meetings record (for browse filters). */
+export function getMeetingCommittees(): string[] {
+ return safeAll<{ committee: string }>(
+ `SELECT DISTINCT committee FROM meetings
+ WHERE committee IS NOT NULL AND committee != ''
+ ORDER BY committee`,
+ {},
+ (r) => ({ committee: str(r.committee) })
+ ).map((r) => r.committee);
+}
+
+
+
export type CategorySummary = {
slug: string;
label: string;
services/ingest/ingest/run.py +2 -0
@@ -33,6 +33,7 @@ logger = logging.getLogger("ingest.run")
# then votes (need members), procedural, lobbying, and finally state + coverage.
PIPELINE = [
("members", "ingest.pollers.members", []),
+ ("member_photos", "ingest.pollers.member_photos", []),
("usaspending", "ingest.pollers.usaspending", []),
("treasury", "ingest.pollers.treasury", []),
("federal_register","ingest.pollers.federal_register",[]),
@@ -48,6 +49,7 @@ PIPELINE = [
("votes_house", "ingest.pollers.votes_house", []),
("votes_enrich", "ingest.pollers.votes_enrich", []),
("crec", "ingest.pollers.crec", []),
+ ("meetings", "ingest.pollers.meetings", []),
("lobbying", "ingest.pollers.lobbying", []),
("openstates", "ingest.pollers.openstates", []),
("legiscan", "ingest.pollers.legiscan", []),