| @@ -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. |
| @@ -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); | |
| + | } |
| @@ -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 ? ( |
| @@ -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"> |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> |
| @@ -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> | ||
| ))} |
| @@ -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} /> : "-"} |
| @@ -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; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> |
| @@ -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> |
| @@ -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" }, |
| @@ -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; |
| @@ -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", []), |