| 1 | "use client"; |
| 2 | |
| 3 | import { useMemo, useState } from "react"; |
| 4 | import { Card } from "@/components/ui"; |
| 5 | import { SourceLink } from "@/components/source-link"; |
| 6 | import { clsx } from "@/lib/cx"; |
| 7 | import type { Meeting } from "@/lib/feed"; |
| 8 | |
| 9 | |
| 10 | type Scope = "upcoming" | "past" | "all"; |
| 11 | |
| 12 | type DayGroup = { date: string; items: Meeting[] }; |
| 13 | |
| 14 | type TypeMeta = { plain: string; color: string }; |
| 15 | |
| 16 | const TYPE_META: Record<string, TypeMeta> = { |
| 17 | hearing: { plain: "Hearing", color: "var(--dk-hold)" }, |
| 18 | markup: { plain: "Markup", color: "var(--dk-party-d)" }, |
| 19 | business_meeting: { plain: "Business meeting", color: "var(--dk-party-i)" }, |
| 20 | floor: { plain: "Floor", color: "var(--dk-party-r)" }, |
| 21 | }; |
| 22 | |
| 23 | function typeMeta(t: string): TypeMeta { |
| 24 | return ( |
| 25 | TYPE_META[(t || "").toLowerCase()] ?? { |
| 26 | plain: (t || "Meeting").replace(/_/g, " "), |
| 27 | color: "var(--dk-ash)", |
| 28 | } |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | const MONTHS = [ |
| 33 | "January", "February", "March", "April", "May", "June", |
| 34 | "July", "August", "September", "October", "November", "December", |
| 35 | ]; |
| 36 | const WEEKDAYS = [ |
| 37 | "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", |
| 38 | ]; |
| 39 | |
| 40 | function formatDayHeader(iso: string): string { |
| 41 | const d = new Date(iso.length === 10 ? iso + "T00:00:00Z" : iso); |
| 42 | if (Number.isNaN(d.getTime())) return iso; |
| 43 | return d.toLocaleDateString("en-US", { |
| 44 | timeZone: "UTC", |
| 45 | weekday: "long", |
| 46 | month: "long", |
| 47 | day: "numeric", |
| 48 | year: "numeric", |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | |
| 53 | function formatTime(m: Meeting): string { |
| 54 | const t = (m.time || "").trim(); |
| 55 | if (!t) return ""; |
| 56 | const ampm = t.match(/^(\d{1,2}):(\d{2})\s*([AaPp])\.?[Mm]\.?$/); |
| 57 | if (ampm) { |
| 58 | const h = parseInt(ampm[1], 10); |
| 59 | const h12 = h === 0 ? 12 : h; |
| 60 | return `${h12}:${ampm[2]} ${ampm[3].toUpperCase()}M`; |
| 61 | } |
| 62 | const parts = t.split(":"); |
| 63 | const hh = parseInt(parts[0], 10); |
| 64 | const mm = parseInt(parts[1], 10); |
| 65 | if (!Number.isFinite(hh) || !Number.isFinite(mm)) return ""; |
| 66 | const period = hh < 12 ? "AM" : "PM"; |
| 67 | const h12 = ((hh + 11) % 12) + 1; |
| 68 | return `${h12}:${String(mm).padStart(2, "0")} ${period}`; |
| 69 | } |
| 70 | |
| 71 | function dedash(s: string): string { |
| 72 | return s ? s.replace(/--/g, "-") : s; |
| 73 | } |
| 74 | |
| 75 | export function MeetingsBrowser({ |
| 76 | seed, |
| 77 | committees, |
| 78 | todayIso, |
| 79 | }: { |
| 80 | seed: Meeting[]; |
| 81 | committees: string[]; |
| 82 | todayIso: string; |
| 83 | }) { |
| 84 | const [q, setQ] = useState(""); |
| 85 | const [chamber, setChamber] = useState(""); |
| 86 | const [type, setType] = useState(""); |
| 87 | const [committee, setCommittee] = useState(""); |
| 88 | const [scope, setScope] = useState<Scope>("upcoming"); |
| 89 | |
| 90 | const filtered = useMemo(() => { |
| 91 | const query = q.trim().toLowerCase(); |
| 92 | return seed.filter((m) => { |
| 93 | if ( |
| 94 | query && |
| 95 | !m.committee.toLowerCase().includes(query) && |
| 96 | !m.subject.toLowerCase().includes(query) && |
| 97 | !m.title.toLowerCase().includes(query) |
| 98 | ) { |
| 99 | return false; |
| 100 | } |
| 101 | if (chamber && m.chamber.toLowerCase() !== chamber.toLowerCase()) return false; |
| 102 | if (type && m.type.toLowerCase() !== type.toLowerCase()) return false; |
| 103 | if (committee && m.committee !== committee) return false; |
| 104 | if (scope === "upcoming" && m.date && m.date < todayIso) return false; |
| 105 | if (scope === "past" && (!m.date || m.date >= todayIso)) return false; |
| 106 | return true; |
| 107 | }); |
| 108 | }, [seed, q, chamber, type, committee, scope, todayIso]); |
| 109 | |
| 110 | const groups = useMemo(() => groupByDay(filtered, scope === "past"), [filtered, scope]); |
| 111 | |
| 112 | const upcomingCount = useMemo( |
| 113 | () => seed.filter((m) => !m.date || m.date >= todayIso).length, |
| 114 | [seed, todayIso] |
| 115 | ); |
| 116 | const pastCount = seed.length - upcomingCount; |
| 117 | const hasAny = seed.length > 0; |
| 118 | |
| 119 | function clearAll() { |
| 120 | setQ(""); |
| 121 | setChamber(""); |
| 122 | setType(""); |
| 123 | setCommittee(""); |
| 124 | } |
| 125 | const filtering = q || chamber || type || committee; |
| 126 | |
| 127 | return ( |
| 128 | <> |
| 129 | {} |
| 130 | <Card className="p-4 sm:p-5"> |
| 131 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> |
| 132 | <Field label="Search"> |
| 133 | <input |
| 134 | type="search" |
| 135 | value={q} |
| 136 | onChange={(e) => setQ(e.target.value)} |
| 137 | placeholder="Committee or subject" |
| 138 | aria-label="Search meetings by committee or subject" |
| 139 | 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" |
| 140 | /> |
| 141 | </Field> |
| 142 | <Field label="Chamber"> |
| 143 | <Select |
| 144 | ariaLabel="Filter by chamber" |
| 145 | value={chamber} |
| 146 | onChange={setChamber} |
| 147 | options={[ |
| 148 | { value: "", label: "Both chambers" }, |
| 149 | { value: "senate", label: "Senate" }, |
| 150 | { value: "house", label: "House" }, |
| 151 | ]} |
| 152 | /> |
| 153 | </Field> |
| 154 | <Field label="Type"> |
| 155 | <Select |
| 156 | ariaLabel="Filter by meeting type" |
| 157 | value={type} |
| 158 | onChange={setType} |
| 159 | options={[ |
| 160 | { value: "", label: "All types" }, |
| 161 | { value: "hearing", label: "Hearing" }, |
| 162 | { value: "markup", label: "Markup" }, |
| 163 | { value: "business_meeting", label: "Business meeting" }, |
| 164 | { value: "floor", label: "Floor" }, |
| 165 | ]} |
| 166 | /> |
| 167 | </Field> |
| 168 | <Field label="Committee"> |
| 169 | <Select |
| 170 | ariaLabel="Filter by committee" |
| 171 | value={committee} |
| 172 | onChange={setCommittee} |
| 173 | options={[ |
| 174 | { value: "", label: "All committees" }, |
| 175 | ...committees.map((c) => ({ value: c, label: c })), |
| 176 | ]} |
| 177 | /> |
| 178 | </Field> |
| 179 | </div> |
| 180 | |
| 181 | {} |
| 182 | <div |
| 183 | role="group" |
| 184 | aria-label="Show upcoming or past meetings" |
| 185 | className="mt-4 flex flex-wrap items-center gap-1" |
| 186 | > |
| 187 | {( |
| 188 | [ |
| 189 | { value: "upcoming", label: `Upcoming · ${upcomingCount}` }, |
| 190 | { value: "past", label: `Past · ${pastCount}` }, |
| 191 | { value: "all", label: `All · ${seed.length}` }, |
| 192 | ] as Array<{ value: Scope; label: string }> |
| 193 | ).map((opt) => { |
| 194 | const active = scope === opt.value; |
| 195 | return ( |
| 196 | <button |
| 197 | key={opt.value} |
| 198 | type="button" |
| 199 | onClick={() => setScope(opt.value)} |
| 200 | aria-pressed={active} |
| 201 | className={clsx( |
| 202 | "focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border px-3", |
| 203 | active |
| 204 | ? "border-paper text-paper" |
| 205 | : "border-line text-ash hover:text-paper" |
| 206 | )} |
| 207 | > |
| 208 | {opt.label} |
| 209 | </button> |
| 210 | ); |
| 211 | })} |
| 212 | {filtering ? ( |
| 213 | <button |
| 214 | type="button" |
| 215 | onClick={clearAll} |
| 216 | 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" |
| 217 | > |
| 218 | Clear filters |
| 219 | </button> |
| 220 | ) : null} |
| 221 | </div> |
| 222 | </Card> |
| 223 | |
| 224 | {} |
| 225 | <div className="mt-6"> |
| 226 | {!hasAny ? ( |
| 227 | <Card className="p-8 text-center"> |
| 228 | <p className="display text-2xl text-paper">No meetings on record yet.</p> |
| 229 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 230 | Congressional committee and floor meetings will appear here - when |
| 231 | committees hold them, each sealed to its official schedule. |
| 232 | </p> |
| 233 | </Card> |
| 234 | ) : groups.length === 0 ? ( |
| 235 | <Card className="p-8 text-center"> |
| 236 | <p className="display text-2xl text-paper">No meetings match.</p> |
| 237 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 238 | {scope === "upcoming" |
| 239 | ? "No upcoming meetings match the filters. Try Past, or clear a filter." |
| 240 | : "Adjust the filters above."} |
| 241 | </p> |
| 242 | </Card> |
| 243 | ) : ( |
| 244 | <div className="space-y-8"> |
| 245 | {groups.map((g) => ( |
| 246 | <section key={g.date || "undated"} aria-label={formatDayHeader(g.date)}> |
| 247 | <div className="mb-3 flex items-baseline justify-between gap-3 border-b border-line pb-2"> |
| 248 | <h3 className="font-mono text-[14px] text-paper"> |
| 249 | {g.date ? formatDayHeader(g.date) : "Date not on record"} |
| 250 | </h3> |
| 251 | <span className="label text-ash-2"> |
| 252 | {g.items.length} meeting{g.items.length === 1 ? "" : "s"} |
| 253 | </span> |
| 254 | </div> |
| 255 | <div className="grid gap-3 lg:grid-cols-2"> |
| 256 | {g.items.map((m) => ( |
| 257 | <MeetingCard key={m.id} m={m} /> |
| 258 | ))} |
| 259 | </div> |
| 260 | </section> |
| 261 | ))} |
| 262 | </div> |
| 263 | )} |
| 264 | </div> |
| 265 | </> |
| 266 | ); |
| 267 | } |
| 268 | |
| 269 | function MeetingCard({ m }: { m: Meeting }) { |
| 270 | const tm = typeMeta(m.type); |
| 271 | const time = formatTime(m); |
| 272 | const cancelled = /cancel/i.test(m.status); |
| 273 | return ( |
| 274 | <article className="flex flex-col gap-2 rounded-[3px] border border-line bg-surface p-4"> |
| 275 | <div className="flex flex-wrap items-center gap-2"> |
| 276 | <span |
| 277 | className="pill" |
| 278 | style={{ |
| 279 | color: tm.color, |
| 280 | borderColor: `color-mix(in srgb, ${tm.color} 45%, transparent)`, |
| 281 | }} |
| 282 | > |
| 283 | {tm.plain} |
| 284 | </span> |
| 285 | <span className="label text-ash-2"> |
| 286 | {m.chamber ? cap(m.chamber) : "Congress"} |
| 287 | </span> |
| 288 | {time ? ( |
| 289 | <span className="label text-paper">{time}</span> |
| 290 | ) : null} |
| 291 | {m.status ? ( |
| 292 | <span |
| 293 | className={clsx("pill", cancelled ? "text-nay" : "text-ash")} |
| 294 | style={ |
| 295 | cancelled |
| 296 | ? { borderColor: "color-mix(in srgb, var(--dk-nay) 45%, transparent)" } |
| 297 | : undefined |
| 298 | } |
| 299 | > |
| 300 | {cap(m.status)} |
| 301 | </span> |
| 302 | ) : null} |
| 303 | </div> |
| 304 | <p className="font-mono text-[13px] text-paper"> |
| 305 | {m.committee ? dedash(m.committee) : "Committee not on record"} |
| 306 | </p> |
| 307 | {m.subject ? ( |
| 308 | <p className="font-mono text-[13px] leading-relaxed text-ash">{dedash(m.subject)}</p> |
| 309 | ) : null} |
| 310 | {} |
| 311 | {m.title && m.title.trim() !== m.subject.trim() ? ( |
| 312 | <p className="font-mono text-[13px] leading-relaxed text-ash-2">{dedash(m.title)}</p> |
| 313 | ) : null} |
| 314 | {m.location ? ( |
| 315 | <p className="label text-ash-2">Location · {m.location}</p> |
| 316 | ) : null} |
| 317 | <div className="mt-1"> |
| 318 | <SourceLink url={m.sourceUrl} label="Schedule" /> |
| 319 | </div> |
| 320 | </article> |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | function Field({ label, children }: { label: string; children: React.ReactNode }) { |
| 325 | return ( |
| 326 | <label className="block"> |
| 327 | <span className="label mb-1.5 block">{label}</span> |
| 328 | {children} |
| 329 | </label> |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | function Select({ |
| 334 | value, |
| 335 | onChange, |
| 336 | ariaLabel, |
| 337 | options, |
| 338 | }: { |
| 339 | value: string; |
| 340 | onChange: (v: string) => void; |
| 341 | ariaLabel: string; |
| 342 | options: Array<{ value: string; label: string }>; |
| 343 | }) { |
| 344 | return ( |
| 345 | <select |
| 346 | value={value} |
| 347 | onChange={(e) => onChange(e.target.value)} |
| 348 | aria-label={ariaLabel} |
| 349 | className={clsx( |
| 350 | "focus-ring w-full rounded-[2px] border border-line bg-ink px-3 py-2", |
| 351 | "font-mono text-[13px] text-paper" |
| 352 | )} |
| 353 | > |
| 354 | {options.map((o) => ( |
| 355 | <option key={o.value} value={o.value} className="bg-surface text-paper"> |
| 356 | {o.label} |
| 357 | </option> |
| 358 | ))} |
| 359 | </select> |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | function groupByDay(list: Meeting[], desc: boolean): DayGroup[] { |
| 364 | const map = new Map<string, Meeting[]>(); |
| 365 | for (const m of list) { |
| 366 | const key = m.date || ""; |
| 367 | const arr = map.get(key) ?? []; |
| 368 | arr.push(m); |
| 369 | map.set(key, arr); |
| 370 | } |
| 371 | for (const arr of map.values()) { |
| 372 | arr.sort((a, b) => { |
| 373 | const ta = a.datetimeIso || `${a.date} ${a.time}`; |
| 374 | const tb = b.datetimeIso || `${b.date} ${b.time}`; |
| 375 | return desc ? tb.localeCompare(ta) : ta.localeCompare(tb); |
| 376 | }); |
| 377 | } |
| 378 | const keys = Array.from(map.keys()); |
| 379 | keys.sort((a, b) => (desc ? b.localeCompare(a) : a.localeCompare(b))); |
| 380 | keys.sort((a, b) => { |
| 381 | if (!a && b) return 1; |
| 382 | if (a && !b) return -1; |
| 383 | return 0; |
| 384 | }); |
| 385 | return keys.map((date) => ({ date, items: map.get(date)! })); |
| 386 | } |
| 387 | |
| 388 | function cap(s: string): string { |
| 389 | return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; |
| 390 | } |