● Zion Boggan
repos/Docket/components/meetings-browser.tsx
zionboggan.com ↗
390 lines · tsx
History for this file →
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
}