● Zion Boggan
repos/Docket/components/calendar-browser.tsx
zionboggan.com ↗
594 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useEffect, useState } from "react";
4
import { useSearchParams } from "next/navigation";
5
import Link from "next/link";
6
import { Card, SectionHeader } from "@/components/ui";
7
import { SourceLink } from "@/components/source-link";
8
import type {
9
  CalendarEvent,
10
  CalendarKind,
11
  CalendarDayKind,
12
} from "@/lib/feed";
13
import { clsx } from "@/lib/cx";
14
 
15
 
16
type KindMeta = { value: CalendarKind; plain: string; mark: string; color: string };
17
 
18
const KINDS: KindMeta[] = [
19
  { value: "effective", plain: "Takes effect", mark: "Effective", color: "var(--dk-hold)" },
20
  { value: "effective_parsed", plain: "Takes effect", mark: "Effective", color: "var(--dk-hold)" },
21
  { value: "enacted", plain: "Became law", mark: "Law", color: "var(--dk-yea)" },
22
  { value: "vote", plain: "Vote", mark: "Vote", color: "var(--dk-party-d)" },
23
  { value: "floor", plain: "Floor action", mark: "Floor", color: "var(--dk-party-r)" },
24
  { value: "committee", plain: "Committee", mark: "Committee", color: "var(--dk-party-i)" },
25
  { value: "introduced", plain: "Introduced", mark: "Introduced", color: "var(--dk-ash)" },
26
  { value: "apportionment", plain: "Apportionment", mark: "Appr.", color: "var(--dk-hold)" },
27
];
28
 
29
const KIND_BY_VALUE = new Map<CalendarKind, KindMeta>(KINDS.map((k) => [k.value, k]));
30
 
31
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
32
const MONTH_NAMES = [
33
  "January", "February", "March", "April", "May", "June",
34
  "July", "August", "September", "October", "November", "December",
35
];
36
 
37
function pad(n: number): string {
38
  return n < 10 ? `0${n}` : String(n);
39
}
40
 
41
function isoToday(): string {
42
  const d = new Date();
43
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
44
}
45
 
46
function parseMonth(raw: string | undefined): { year: number; month: number } {
47
  const m = /^(\d{4})-(\d{1,2})$/.exec(raw ?? "");
48
  if (m) {
49
    const year = parseInt(m[1], 10);
50
    const month = parseInt(m[2], 10);
51
    if (year >= 1900 && year <= 9999 && month >= 1 && month <= 12) {
52
      return { year, month };
53
    }
54
  }
55
  const d = new Date();
56
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 };
57
}
58
 
59
function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } {
60
  const zero = month - 1 + delta;
61
  const d = new Date(Date.UTC(year, zero, 1));
62
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 };
63
}
64
 
65
export function CalendarBrowser() {
66
  const sp = useSearchParams();
67
  const monthParam = sp.get("month") || undefined;
68
  const dayParam = sp.get("day") || undefined;
69
  const kindFilter = sp.get("kind") || undefined;
70
 
71
  const { year, month } = parseMonth(monthParam);
72
  const todayIso = isoToday();
73
 
74
  const selectedDay =
75
    dayParam && /^\d{4}-\d{2}-\d{2}$/.test(dayParam) ? dayParam : undefined;
76
 
77
  const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
78
  const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay();
79
  const from = `${year}-${pad(month)}-01`;
80
  const to = `${year}-${pad(month)}-${pad(daysInMonth)}`;
81
 
82
  const [daySummary, setDaySummary] = useState<CalendarDayKind[]>([]);
83
  const [dayEvents, setDayEvents] = useState<CalendarEvent[]>([]);
84
  const [loading, setLoading] = useState(true);
85
 
86
  useEffect(() => {
87
    setLoading(true);
88
    fetch(`/api/calendar?from=${from}&to=${to}&summary=1`)
89
      .then((r) => r.json())
90
      .then((data) => setDaySummary(data.items ?? []))
91
      .catch(() => setDaySummary([]))
92
      .finally(() => setLoading(false));
93
  }, [from, to]);
94
 
95
  useEffect(() => {
96
    if (!selectedDay) {
97
      setDayEvents([]);
98
      return;
99
    }
100
    const params = new URLSearchParams();
101
    params.set("from", selectedDay);
102
    params.set("to", selectedDay);
103
    if (kindFilter) params.set("kind", kindFilter);
104
    params.set("limit", "200");
105
    fetch(`/api/calendar?${params.toString()}`)
106
      .then((r) => r.json())
107
      .then((data) => setDayEvents(data.items ?? []))
108
      .catch(() => setDayEvents([]));
109
  }, [selectedDay, kindFilter]);
110
 
111
  const filteredSummary = kindFilter
112
    ? daySummary.filter((s) => s.kind === kindFilter)
113
    : daySummary;
114
  const byDate = new Map<string, CalendarDayKind[]>();
115
  for (const s of filteredSummary) {
116
    const arr = byDate.get(s.date) ?? [];
117
    arr.push(s);
118
    byDate.set(s.date, arr);
119
  }
120
 
121
  const kindCounts = countKindsFromSummary(filteredSummary);
122
 
123
  const prev = shiftMonth(year, month, -1);
124
  const next = shiftMonth(year, month, +1);
125
  const prevHref = hrefFor(prev, selectedDay, kindFilter);
126
  const nextHref = hrefFor(next, selectedDay, kindFilter);
127
  const todayHref = hrefFor({ year: todayYear(), month: todayMonth() }, todayIso, kindFilter);
128
 
129
  const cells: Array<number | null> = [];
130
  for (let i = 0; i < firstWeekday; i++) cells.push(null);
131
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);
132
  while (cells.length % 7 !== 0) cells.push(null);
133
 
134
  return (
135
    <>
136
      {}
137
      <div className="mt-8 flex flex-wrap items-center justify-between gap-3">
138
        <div className="flex items-center gap-1">
139
          <Link
140
            href={prevHref}
141
            className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash hover:text-paper"
142
            aria-label={`Previous month, ${MONTH_NAMES[prev.month - 1]} ${prev.year}`}
143
          >
144
            ← {MONTH_NAMES[prev.month - 1].slice(0, 3)}
145
          </Link>
146
          <p
147
            className="display px-2 text-paper"
148
            aria-live="polite"
149
            style={{ fontSize: "clamp(1.25rem, 5vw, 1.875rem)" }}
150
          >
151
            {MONTH_NAMES[month - 1]} {year}
152
          </p>
153
          <Link
154
            href={nextHref}
155
            className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash hover:text-paper"
156
            aria-label={`Next month, ${MONTH_NAMES[next.month - 1]} ${next.year}`}
157
          >
158
            {MONTH_NAMES[next.month - 1].slice(0, 3)} →
159
          </Link>
160
        </div>
161
        <Link
162
          href={todayHref}
163
          className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash underline underline-offset-4 hover:text-paper"
164
        >
165
          Today
166
        </Link>
167
      </div>
168
 
169
      {}
170
      <div className="mt-5 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by kind">
171
        <Link
172
          href={hrefFor({ year, month }, selectedDay, undefined)}
173
          className={clsx(
174
            "focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border px-2.5",
175
            !kindFilter ? "border-paper text-paper" : "border-line text-ash hover:text-paper"
176
          )}
177
          aria-pressed={!kindFilter}
178
        >
179
          All
180
        </Link>
181
        {KINDS.filter((k, i) => KINDS.findIndex((x) => x.mark === k.mark) === i).map((k) => {
182
          const active = kindFilter === k.value;
183
          return (
184
            <Link
185
              key={k.value}
186
              href={hrefFor({ year, month }, selectedDay, active ? undefined : k.value)}
187
              className={clsx(
188
                "focus-ring label inline-flex min-h-[44px] items-center gap-1.5 rounded-[3px] border px-2.5",
189
                active ? "border-paper text-paper" : "border-line text-ash hover:text-paper"
190
              )}
191
              aria-pressed={active}
192
            >
193
              <span
194
                aria-hidden="true"
195
                className="inline-block h-2 w-2 rounded-full"
196
                style={{ backgroundColor: k.color }}
197
              />
198
              {k.plain}
199
            </Link>
200
          );
201
        })}
202
      </div>
203
 
204
      <div className="mt-6 grid gap-6 lg:grid-cols-[1.5fr_1fr]">
205
        {}
206
        <div>
207
          {}
208
          <Card className="hidden overflow-hidden md:block">
209
            <table className="w-full table-fixed border-collapse text-left">
210
              <caption className="sr-only">
211
                Calendar grid for {MONTH_NAMES[month - 1]} {year}. Each cell is a
212
                day; select a day to read its events. Rows are weeks, columns
213
                are weekdays starting Sunday.
214
              </caption>
215
              <thead>
216
                <tr className="border-b border-line">
217
                  {WEEKDAYS.map((w) => (
218
                    <th key={w} scope="col" className="label px-2 py-2.5 font-normal">
219
                      {w}
220
                    </th>
221
                  ))}
222
                </tr>
223
              </thead>
224
              <tbody>
225
                {chunk(cells, 7).map((row, ri) => (
226
                  <tr key={ri} className="border-b border-line last:border-0">
227
                    {row.map((day, ci) => {
228
                      if (day == null) {
229
                        return (
230
                          <td key={ci} aria-hidden="true" className="bg-ink/40 p-0">
231
                            <div className="min-h-[5.5rem]" />
232
                          </td>
233
                        );
234
                      }
235
                      const dateStr = `${year}-${pad(month)}-${pad(day)}`;
236
                      const evs = byDate.get(dateStr) ?? [];
237
                      const evCount = dayEventCount(evs);
238
                      const isToday = dateStr === todayIso;
239
                      const isSelected = dateStr === selectedDay;
240
                      const marks = distinctMarks(evs);
241
                      const marksLabel = marks.map((m) => m.plain).join(", ");
242
                      return (
243
                        <td key={ci} className="border-r border-line p-0 align-top last:border-r-0">
244
                          <Link
245
                            href={hrefFor({ year, month }, dateStr, kindFilter)}
246
                            aria-label={`${day} ${MONTH_NAMES[month - 1]} ${year}${evCount ? `, ${evCount} event${evCount === 1 ? "" : "s"}${marksLabel ? `: ${marksLabel}` : ""}` : ", no events"}`}
247
                            aria-current={isSelected ? "true" : undefined}
248
                            className={clsx(
249
                              "focus-ring flex h-full min-h-[5.5rem] flex-col justify-between p-2 no-underline",
250
                              isSelected ? "bg-graphite" : "hover:bg-graphite/50"
251
                            )}
252
                          >
253
                            <span
254
                              className={clsx(
255
                                "flex h-7 w-7 items-center justify-center rounded-full font-mono text-[13px] tabular-nums",
256
                                isToday
257
                                  ? "bg-signal text-ink"
258
                                  : evCount
259
                                    ? "text-paper"
260
                                    : "text-ash-2"
261
                              )}
262
                            >
263
                              {day}
264
                            </span>
265
                            {evCount > 0 ? (
266
                              <span className="mt-1.5 flex items-center gap-1.5" aria-hidden="true">
267
                                <span className="flex flex-wrap gap-1">
268
                                  {marks.slice(0, 4).map((m, i) => (
269
                                    <span
270
                                      key={i}
271
                                      className="inline-block h-2 w-2 shrink-0 rounded-full"
272
                                      style={{ backgroundColor: m.color }}
273
                                      title={m.plain}
274
                                    />
275
                                  ))}
276
                                </span>
277
                                <span className="label !text-[10px] tabular-nums text-ash-2">
278
                                  {evCount}
279
                                </span>
280
                              </span>
281
                            ) : null}
282
                          </Link>
283
                        </td>
284
                      );
285
                    })}
286
                  </tr>
287
                ))}
288
              </tbody>
289
            </table>
290
            <p className="label border-t border-line px-3 py-2 text-ash-2">
291
              {loading
292
                ? "Loading events…"
293
                : `${filteredSummary.reduce((s, e) => s + e.count, 0)} event${filteredSummary.reduce((s, e) => s + e.count, 0) === 1 ? "" : "s"} this month. Select a day to read them.`}
294
            </p>
295
          </Card>
296
 
297
          {}
298
          <div className="md:hidden">
299
            <MobileAgenda
300
              daySummary={filteredSummary}
301
              kindByValue={KIND_BY_VALUE}
302
              selectedDay={selectedDay}
303
              dayEvents={dayEvents}
304
              year={year}
305
              month={month}
306
              kindFilter={kindFilter}
307
              loading={loading}
308
            />
309
          </div>
310
        </div>
311
 
312
        {}
313
        <div className="space-y-6">
314
          <Card className="p-5">
315
            <p className="label mb-3">Legend</p>
316
            <ul className="space-y-2">
317
              {KINDS.filter((k, i) => KINDS.findIndex((x) => x.mark === k.mark) === i).map((k) => (
318
                <li key={k.value} className="flex items-center gap-2.5">
319
                  <span
320
                    aria-hidden="true"
321
                    className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
322
                    style={{ backgroundColor: k.color }}
323
                  />
324
                  <span className="font-mono text-[12px] text-paper">{k.plain}</span>
325
                  <span className="label text-ash-2">{kindCounts.get(k.value) ?? 0}</span>
326
                </li>
327
              ))}
328
            </ul>
329
            <p className="label mt-4 text-ash-2">
330
              Colors are a cue only. Each mark carries its name, so the grid
331
              reads clearly in Plain mode too.
332
            </p>
333
          </Card>
334
 
335
          <div>
336
            <SectionHeader
337
              eyebrow={selectedDay ? "Selected day" : "Pick a day"}
338
              title={selectedDay ? formatLong(selectedDay) : "Day events"}
339
            />
340
            {!selectedDay ? (
341
              <Card className="p-5">
342
                <p className="font-mono text-[13px] text-ash-2">
343
                  Select a day in the grid to read its events. Each event links
344
                  to its official source.
345
                </p>
346
              </Card>
347
            ) : dayEvents.length === 0 ? (
348
              <Card className="p-5">
349
                <p className="font-mono text-[13px] text-ash-2">
350
                  No events on record for {formatLong(selectedDay)}
351
                  {kindFilter ? " for this filter" : ""}.
352
                </p>
353
              </Card>
354
            ) : (
355
              <Card className="divide-y divide-[color:var(--dk-line)]">
356
                {dayEvents.map((e) => {
357
                  const k = KIND_BY_VALUE.get(e.kind);
358
                  return (
359
                    <div key={e.id} className="p-4">
360
                      <div className="flex flex-wrap items-center gap-2">
361
                        {k ? (
362
                          <span
363
                            className="pill"
364
                            style={{
365
                              color: k.color,
366
                              borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`,
367
                            }}
368
                          >
369
                            {k.plain}
370
                          </span>
371
                        ) : (
372
                          <span className="pill text-ash">{e.kind}</span>
373
                        )}
374
                        <span className="label text-ash-2">{e.refType}</span>
375
                      </div>
376
                      <p className="mt-2 font-mono text-[12px] leading-relaxed text-paper">
377
                        {e.href ? (
378
                          <Link
379
                            href={e.href}
380
                            className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
381
                          >
382
                            {e.title}
383
                          </Link>
384
                        ) : (
385
                          e.title
386
                        )}
387
                      </p>
388
                      <div className="mt-2">
389
                        <SourceLink url={e.sourceUrl} />
390
                      </div>
391
                    </div>
392
                  );
393
                })}
394
              </Card>
395
            )}
396
          </div>
397
        </div>
398
      </div>
399
    </>
400
  );
401
}
402
 
403
 
404
function hrefFor(
405
  ym: { year: number; month: number },
406
  day: string | undefined,
407
  kind: string | undefined
408
): string {
409
  const params = new URLSearchParams();
410
  params.set("month", `${ym.year}-${pad(ym.month)}`);
411
  if (day) params.set("day", day);
412
  if (kind) params.set("kind", kind);
413
  return `/calendar?${params.toString()}`;
414
}
415
 
416
function todayYear(): number {
417
  return new Date().getUTCFullYear();
418
}
419
function todayMonth(): number {
420
  return new Date().getUTCMonth() + 1;
421
}
422
 
423
function chunk<T>(arr: T[], size: number): T[][] {
424
  const out: T[][] = [];
425
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
426
  return out;
427
}
428
 
429
function distinctMarks(evs: CalendarDayKind[]): KindMeta[] {
430
  const seen = new Set<string>();
431
  const out: KindMeta[] = [];
432
  for (const e of evs) {
433
    const m = KIND_BY_VALUE.get(e.kind);
434
    if (m && !seen.has(m.mark)) {
435
      seen.add(m.mark);
436
      out.push(m);
437
    }
438
  }
439
  return out;
440
}
441
 
442
function dayEventCount(evs: CalendarDayKind[]): number {
443
  return evs.reduce((s, e) => s + e.count, 0);
444
}
445
 
446
function countKindsFromSummary(summary: CalendarDayKind[]): Map<CalendarKind, number> {
447
  const m = new Map<CalendarKind, number>();
448
  for (const s of summary) m.set(s.kind, (m.get(s.kind) ?? 0) + s.count);
449
  return m;
450
}
451
 
452
function formatLong(iso: string): string {
453
  const d = new Date(iso + "T00:00:00Z");
454
  if (Number.isNaN(d.getTime())) return iso;
455
  return d.toLocaleDateString("en-US", {
456
    timeZone: "UTC",
457
    weekday: "long",
458
    year: "numeric",
459
    month: "long",
460
    day: "numeric",
461
  });
462
}
463
 
464
 
465
function MobileAgenda({
466
  daySummary,
467
  kindByValue,
468
  selectedDay,
469
  dayEvents,
470
  year,
471
  month,
472
  kindFilter,
473
  loading,
474
}: {
475
  daySummary: CalendarDayKind[];
476
  kindByValue: Map<CalendarKind, KindMeta>;
477
  selectedDay: string | undefined;
478
  dayEvents: CalendarEvent[];
479
  year: number;
480
  month: number;
481
  kindFilter: string | undefined;
482
  loading: boolean;
483
}) {
484
  const byDate = new Map<string, CalendarDayKind[]>();
485
  for (const s of daySummary) {
486
    const arr = byDate.get(s.date) ?? [];
487
    arr.push(s);
488
    byDate.set(s.date, arr);
489
  }
490
  const days = Array.from(byDate.keys()).sort();
491
 
492
  if (loading) {
493
    return (
494
      <Card className="p-5">
495
        <p className="font-mono text-[13px] text-ash-2">Loading events…</p>
496
      </Card>
497
    );
498
  }
499
 
500
  if (days.length === 0) {
501
    return (
502
      <Card className="p-5">
503
        <p className="font-mono text-[13px] text-ash-2">
504
          No events on record for this month. Month navigation above moves to
505
          other months; each event will link to its official source.
506
        </p>
507
      </Card>
508
    );
509
  }
510
 
511
  return (
512
    <div className="space-y-2">
513
      {days.map((d) => {
514
        const evs = byDate.get(d)!;
515
        const count = dayEventCount(evs);
516
        const marks = distinctMarks(evs);
517
        const isSelected = d === selectedDay;
518
        return (
519
          <section key={d}>
520
            <Link
521
              href={hrefFor({ year, month }, d, kindFilter)}
522
              className={`focus-ring block rounded-[3px] border p-3 no-underline hover:bg-graphite/50 ${isSelected ? "border-paper bg-graphite" : "border-line"}`}
523
              aria-current={isSelected ? "true" : undefined}
524
            >
525
              <div className="flex items-center justify-between gap-3">
526
                <span className="font-mono text-[13px] text-paper">{formatLong(d)}</span>
527
                <span className="label text-ash-2 shrink-0">{count} event{count === 1 ? "" : "s"}</span>
528
              </div>
529
              <div className="mt-1.5 flex flex-wrap gap-1">
530
                {marks.map((m, i) => (
531
                  <span
532
                    key={i}
533
                    className="pill !px-1.5 !py-0"
534
                    style={{
535
                      color: m.color,
536
                      borderColor: `color-mix(in srgb, ${m.color} 45%, transparent)`,
537
                    }}
538
                  >
539
                    {m.plain}
540
                  </span>
541
                ))}
542
              </div>
543
            </Link>
544
            {isSelected && dayEvents.length > 0 && (
545
              <div className="mt-2 space-y-2 pl-3">
546
                {dayEvents.map((e) => {
547
                  const k = kindByValue.get(e.kind);
548
                  return (
549
                    <article
550
                      key={e.id}
551
                      className="rounded-[3px] border border-line bg-surface p-3"
552
                    >
553
                      <div className="flex flex-wrap items-center gap-2">
554
                        {k ? (
555
                          <span
556
                            className="pill !py-0.5"
557
                            style={{
558
                              color: k.color,
559
                              borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`,
560
                            }}
561
                          >
562
                            {k.plain}
563
                          </span>
564
                        ) : (
565
                          <span className="pill !py-0.5 text-ash">{e.kind}</span>
566
                        )}
567
                        <span className="label text-ash-2">{e.refType}</span>
568
                      </div>
569
                      <p className="mt-2 break-words font-mono text-[12px] leading-relaxed text-paper">
570
                        {e.href ? (
571
                          <Link
572
                            href={e.href}
573
                            className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
574
                          >
575
                            {e.title}
576
                          </Link>
577
                        ) : (
578
                          e.title
579
                        )}
580
                      </p>
581
                      <div className="mt-2">
582
                        <SourceLink url={e.sourceUrl} />
583
                      </div>
584
                    </article>
585
                  );
586
                })}
587
              </div>
588
            )}
589
          </section>
590
        );
591
      })}
592
    </div>
593
  );
594
}