@remit/ui 0.0.155 → 0.0.156

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,887 @@
1
+ /**
2
+ * The strip: one scrolling list of days.
3
+ *
4
+ * A time grid spends its pixels in proportion to the hours it covers, which is
5
+ * backwards — most days are mostly empty. This list spends them in proportion
6
+ * to what is on the day: a booked hour gets a row, an empty day gets a line,
7
+ * and a run of empty days gets one sentence. Free stretches are drawn, not
8
+ * left as whitespace, because "your Thursday afternoon is clear" is the answer
9
+ * people actually come to a calendar for.
10
+ *
11
+ * Scrolling never paginates. Reaching either end asks the owner for more days
12
+ * and the scroll position is held across the insert, so the strip has no seams
13
+ * and no page boundaries to lose your place at.
14
+ */
15
+ import { CalendarOff, ChevronRight, Layers, MapPin, Users } from "lucide-react";
16
+ import {
17
+ type ReactNode,
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import {
25
+ type AgendaRow,
26
+ buildAgendaRows,
27
+ busySpansOn,
28
+ DAY_END_MINUTE,
29
+ DAY_START_MINUTE,
30
+ type FreeStretch,
31
+ formatMinute,
32
+ formatRunLabel,
33
+ formatSpan,
34
+ freeStretchesOn,
35
+ groupOverlapping,
36
+ isClearDay,
37
+ minuteOfDay,
38
+ monthLabel,
39
+ weekdayLongLabel,
40
+ } from "../lib/agenda-time.js";
41
+ import { calendarColorClasses } from "../lib/calendar-color.js";
42
+ import { cn } from "../lib/cn.js";
43
+ import { CalendarEventChip } from "./calendar-event-chip.js";
44
+ import type {
45
+ CalendarColorId,
46
+ CalendarDay,
47
+ CalendarDescriptor,
48
+ CalendarEventData,
49
+ CalendarSlotPick,
50
+ } from "./calendar-types.js";
51
+
52
+ /**
53
+ * The reading of the strip, from a month at a glance to one event per card.
54
+ * Three steps rather than two: a list can go further in both directions than a
55
+ * grid can, and how far is the reader's call.
56
+ */
57
+ export type AgendaDensity = "dots" | "pills" | "detail";
58
+
59
+ /** The sticky bar the anchors are measured against. */
60
+ const HEADER_HEIGHT = 32;
61
+
62
+ /** A stretch this long stops being a gap and becomes an afternoon. */
63
+ const LONG_FREE_MINUTES = 150;
64
+
65
+ /** The hue every calendar the caller did not describe falls back to. */
66
+ const FALLBACK_COLOR: CalendarColorId = "cal-1";
67
+
68
+ export interface AgendaScrollTarget {
69
+ date: string;
70
+ /** Changes on every request, so asking twice for the same day works. */
71
+ token: number;
72
+ }
73
+
74
+ /** Which calendar owns which hue and name, resolved once per render. */
75
+ interface CalendarLookup {
76
+ color: (calendarId: string) => CalendarColorId;
77
+ name: (calendarId: string) => string;
78
+ }
79
+
80
+ function lookupOf(calendars: readonly CalendarDescriptor[]): CalendarLookup {
81
+ const byId = new Map(calendars.map((calendar) => [calendar.id, calendar]));
82
+ return {
83
+ color: (calendarId) => byId.get(calendarId)?.color ?? FALLBACK_COLOR,
84
+ name: (calendarId) => byId.get(calendarId)?.name ?? "",
85
+ };
86
+ }
87
+
88
+ export interface AgendaFlowProps {
89
+ /** Contiguous and ascending. */
90
+ days: CalendarDay[];
91
+ /** Whose hue and name every event on the strip is drawn with. */
92
+ calendars: readonly CalendarDescriptor[];
93
+ density: AgendaDensity;
94
+ today: string;
95
+ /** Never collapsed into a run, whatever is on it. */
96
+ focusDate: string;
97
+ selectedEventId: string;
98
+ onSelectEvent: (eventId: string) => void;
99
+ onPickSlot: (pick: CalendarSlotPick) => void;
100
+ /** Drops out of the list into the day grid. */
101
+ onZoomDay: (date: string) => void;
102
+ onReachStart: () => void;
103
+ onReachEnd: () => void;
104
+ /** The day under the sticky header, for the position map. */
105
+ onVisibleDayChange: (date: string) => void;
106
+ scrollTarget?: AgendaScrollTarget;
107
+ /**
108
+ * Rendered immediately above today, and landed on with it, so what's next is
109
+ * the first thing you see and the first thing that scrolls away.
110
+ */
111
+ todayLead?: ReactNode;
112
+ touch?: boolean;
113
+ className?: string;
114
+ }
115
+
116
+ export function AgendaFlow({
117
+ days,
118
+ calendars,
119
+ density,
120
+ today,
121
+ focusDate,
122
+ selectedEventId,
123
+ onSelectEvent,
124
+ onPickSlot,
125
+ onZoomDay,
126
+ onReachStart,
127
+ onReachEnd,
128
+ onVisibleDayChange,
129
+ scrollTarget,
130
+ todayLead,
131
+ touch,
132
+ className,
133
+ }: AgendaFlowProps) {
134
+ const scroller = useRef<HTMLDivElement>(null);
135
+ const anchors = useRef(new Map<string, HTMLDivElement>());
136
+ const previousFirst = useRef(days[0]?.date ?? "");
137
+ const previousHeight = useRef(0);
138
+ const askedStart = useRef("");
139
+ const askedEnd = useRef("");
140
+ const landed = useRef(false);
141
+ const [visibleDate, setVisibleDate] = useState(focusDate);
142
+
143
+ const lookup = useMemo(() => lookupOf(calendars), [calendars]);
144
+ const rows = buildAgendaRows(days, [today, focusDate]);
145
+ const firstDate = days[0]?.date ?? "";
146
+ const lastDate = days[days.length - 1]?.date ?? "";
147
+
148
+ /* Prepending days must not move what you are reading, so the scroll offset
149
+ takes back exactly the height that was inserted above it. */
150
+ useLayoutEffect(() => {
151
+ const element = scroller.current;
152
+ if (!element) return;
153
+ if (firstDate !== "" && firstDate < previousFirst.current)
154
+ element.scrollTop += element.scrollHeight - previousHeight.current;
155
+ previousFirst.current = firstDate;
156
+ previousHeight.current = element.scrollHeight;
157
+ });
158
+
159
+ /* Landing puts you on the focused day; after that the strip is yours. Rows
160
+ are still settling on the first frame — a web font swapping under a month
161
+ of them moves the target by whole days — so the landing is re-applied
162
+ until the layout it was measured against stops changing. */
163
+ useEffect(() => {
164
+ if (landed.current) return;
165
+ const element = scroller.current;
166
+ if (!element) return;
167
+ landed.current = true;
168
+ let dropped = false;
169
+ const settle = () => {
170
+ const anchor = anchors.current.get(focusDate);
171
+ if (dropped || !anchor) return;
172
+ element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
173
+ };
174
+ settle();
175
+ setVisibleDate(focusDate);
176
+ const frame = requestAnimationFrame(settle);
177
+ document.fonts?.ready.then(settle);
178
+ return () => {
179
+ dropped = true;
180
+ cancelAnimationFrame(frame);
181
+ };
182
+ }, [focusDate]);
183
+
184
+ useEffect(() => {
185
+ if (!scrollTarget) return;
186
+ const element = scroller.current;
187
+ const anchor = anchors.current.get(scrollTarget.date);
188
+ if (!element || !anchor) return;
189
+ element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
190
+ setVisibleDate(scrollTarget.date);
191
+ onVisibleDayChange(scrollTarget.date);
192
+ }, [scrollTarget, onVisibleDayChange]);
193
+
194
+ const handleScroll = () => {
195
+ const element = scroller.current;
196
+ if (!element) return;
197
+
198
+ const seen = [...anchors.current.entries()]
199
+ .filter(([, node]) => node.isConnected)
200
+ .sort((a, b) => a[1].offsetTop - b[1].offsetTop);
201
+ let current = "";
202
+ for (const [date, node] of seen) {
203
+ if (node.offsetTop - HEADER_HEIGHT - 8 > element.scrollTop) break;
204
+ current = date;
205
+ }
206
+ if (current !== "" && current !== visibleDate) {
207
+ setVisibleDate(current);
208
+ onVisibleDayChange(current);
209
+ }
210
+
211
+ if (element.scrollTop < 600 && askedStart.current !== firstDate) {
212
+ askedStart.current = firstDate;
213
+ onReachStart();
214
+ }
215
+ if (
216
+ element.scrollTop + element.clientHeight > element.scrollHeight - 900 &&
217
+ askedEnd.current !== lastDate
218
+ ) {
219
+ askedEnd.current = lastDate;
220
+ onReachEnd();
221
+ }
222
+ };
223
+
224
+ const registerAnchor = (date: string) => (node: HTMLDivElement | null) => {
225
+ if (node) anchors.current.set(date, node);
226
+ else anchors.current.delete(date);
227
+ };
228
+
229
+ return (
230
+ <div
231
+ ref={scroller}
232
+ onScroll={handleScroll}
233
+ className={cn(
234
+ "relative min-h-0 flex-1 overflow-y-auto bg-surface",
235
+ className,
236
+ )}
237
+ >
238
+ <div className="sticky top-0 z-20 flex h-8 items-center gap-2 border-b border-line bg-surface/95 px-row-inset backdrop-blur">
239
+ <span className="text-xs font-semibold text-fg">
240
+ {weekdayLongLabel(visibleDate)}
241
+ </span>
242
+ <span className="text-xs text-fg-subtle">
243
+ {monthLabel(visibleDate)}
244
+ </span>
245
+ {visibleDate === today && (
246
+ <span className="rounded-full bg-accent-soft px-1.5 text-2xs font-medium text-accent">
247
+ Today
248
+ </span>
249
+ )}
250
+ </div>
251
+
252
+ {rows.map((row) => (
253
+ <FlowRow
254
+ key={row.key}
255
+ row={row}
256
+ lookup={lookup}
257
+ density={density}
258
+ today={today}
259
+ selectedEventId={selectedEventId}
260
+ onSelectEvent={onSelectEvent}
261
+ onPickSlot={onPickSlot}
262
+ onZoomDay={onZoomDay}
263
+ anchorRef={registerAnchor(
264
+ row.kind === "day" ? row.day.date : row.from,
265
+ )}
266
+ lead={row.kind === "day" && row.day.date === today ? todayLead : null}
267
+ touch={touch}
268
+ />
269
+ ))}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /* ------------------------------------------------------------------ */
275
+ /* Rows */
276
+ /* ------------------------------------------------------------------ */
277
+
278
+ interface RowProps {
279
+ row: AgendaRow;
280
+ lookup: CalendarLookup;
281
+ density: AgendaDensity;
282
+ today: string;
283
+ selectedEventId: string;
284
+ onSelectEvent: (eventId: string) => void;
285
+ onPickSlot: (pick: CalendarSlotPick) => void;
286
+ onZoomDay: (date: string) => void;
287
+ anchorRef: (node: HTMLDivElement | null) => void;
288
+ lead?: ReactNode;
289
+ touch?: boolean;
290
+ }
291
+
292
+ function FlowRow({
293
+ row,
294
+ lookup,
295
+ density,
296
+ today,
297
+ selectedEventId,
298
+ onSelectEvent,
299
+ onPickSlot,
300
+ onZoomDay,
301
+ anchorRef,
302
+ lead,
303
+ touch,
304
+ }: RowProps) {
305
+ if (row.kind === "run")
306
+ return (
307
+ <div ref={anchorRef}>
308
+ <EmptyRun
309
+ from={row.from}
310
+ to={row.to}
311
+ days={row.days}
312
+ onPickSlot={onPickSlot}
313
+ touch={touch}
314
+ />
315
+ </div>
316
+ );
317
+
318
+ if (density === "dots")
319
+ return (
320
+ <div ref={anchorRef}>
321
+ {lead}
322
+ <DotsDay
323
+ day={row.day}
324
+ lookup={lookup}
325
+ today={today}
326
+ onSelectEvent={onSelectEvent}
327
+ onZoomDay={onZoomDay}
328
+ selectedEventId={selectedEventId}
329
+ />
330
+ </div>
331
+ );
332
+
333
+ return (
334
+ <div ref={anchorRef}>
335
+ {lead}
336
+ <DayBlock
337
+ day={row.day}
338
+ lookup={lookup}
339
+ density={density}
340
+ today={today}
341
+ selectedEventId={selectedEventId}
342
+ onSelectEvent={onSelectEvent}
343
+ onPickSlot={onPickSlot}
344
+ onZoomDay={onZoomDay}
345
+ touch={touch}
346
+ />
347
+ </div>
348
+ );
349
+ }
350
+
351
+ /**
352
+ * A run of days with nothing on them at all. Six blank screens is a worse
353
+ * answer to "am I free that week" than one line saying so.
354
+ */
355
+ function EmptyRun({
356
+ from,
357
+ to,
358
+ days,
359
+ onPickSlot,
360
+ touch,
361
+ }: {
362
+ from: string;
363
+ to: string;
364
+ days: number;
365
+ onPickSlot: (pick: CalendarSlotPick) => void;
366
+ touch?: boolean;
367
+ }) {
368
+ return (
369
+ <button
370
+ type="button"
371
+ onClick={() =>
372
+ onPickSlot({
373
+ date: from,
374
+ startTime: "10:00",
375
+ endTime: "11:00",
376
+ allDay: false,
377
+ })
378
+ }
379
+ className={cn(
380
+ "flex w-full items-center gap-3 border-y border-dashed border-line bg-surface-sunken px-row-inset text-left outline-none transition-colors hover:border-accent focus-visible:ring-2 focus-visible:ring-ring",
381
+ touch ? "min-h-14 py-3" : "py-2",
382
+ )}
383
+ >
384
+ <CalendarOff className="size-4 shrink-0 text-accent" />
385
+ <span className="text-sm font-medium text-fg">
386
+ {formatRunLabel(from, to)}
387
+ </span>
388
+ <span className="text-xs text-fg-muted">
389
+ {days} days with nothing booked
390
+ </span>
391
+ <span className="ml-auto shrink-0 text-2xs text-fg-subtle">Add</span>
392
+ </button>
393
+ );
394
+ }
395
+
396
+ /** The month-at-a-glance reading: one line a day, colour and shape only. */
397
+ function DotsDay({
398
+ day,
399
+ lookup,
400
+ today,
401
+ selectedEventId,
402
+ onSelectEvent,
403
+ onZoomDay,
404
+ }: {
405
+ day: CalendarDay;
406
+ lookup: CalendarLookup;
407
+ today: string;
408
+ selectedEventId: string;
409
+ onSelectEvent: (eventId: string) => void;
410
+ onZoomDay: (date: string) => void;
411
+ }) {
412
+ const isToday = day.date === today;
413
+ const all = [...day.allDay, ...day.timed];
414
+
415
+ return (
416
+ <div
417
+ className={cn(
418
+ "flex h-8 items-center gap-2 border-b border-line px-row-inset",
419
+ isToday && "bg-accent-soft/30",
420
+ )}
421
+ >
422
+ <button
423
+ type="button"
424
+ onClick={() => onZoomDay(day.date)}
425
+ className={cn(
426
+ "w-16 shrink-0 rounded-sm text-left text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
427
+ isToday ? "font-semibold text-accent" : "text-fg-muted",
428
+ )}
429
+ >
430
+ {day.weekdayLabel} {day.dayNumber}
431
+ </button>
432
+ <div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
433
+ {all.map((event) => (
434
+ <button
435
+ key={event.id}
436
+ type="button"
437
+ title={event.title}
438
+ aria-label={event.title}
439
+ aria-pressed={event.id === selectedEventId}
440
+ onClick={() => onSelectEvent(event.id)}
441
+ className={cn(
442
+ "size-2 shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring",
443
+ calendarColorClasses(lookup.color(event.calendarId)).solid,
444
+ event.id === selectedEventId && "ring-2 ring-ring",
445
+ event.myRsvp === "declined" && "opacity-40",
446
+ )}
447
+ />
448
+ ))}
449
+ </div>
450
+ <BusyBar day={day} className="w-24 shrink-0" />
451
+ <span className="w-24 shrink-0 text-right text-2xs text-fg-subtle">
452
+ {glanceLabel(day)}
453
+ </span>
454
+ </div>
455
+ );
456
+ }
457
+
458
+ /** What the day is worth saying in eleven characters. */
459
+ function glanceLabel(day: CalendarDay): string {
460
+ if (day.timed.length === 0) return "clear";
461
+ const longest = freeStretchesOn(day).reduce(
462
+ (best, stretch) => Math.max(best, stretch.minutes),
463
+ 0,
464
+ );
465
+ if (longest >= LONG_FREE_MINUTES) return `${formatSpan(longest)} free`;
466
+ return `${formatSpan(day.busyMinutes)} booked`;
467
+ }
468
+
469
+ function DayBlock({
470
+ day,
471
+ lookup,
472
+ density,
473
+ today,
474
+ selectedEventId,
475
+ onSelectEvent,
476
+ onPickSlot,
477
+ onZoomDay,
478
+ touch,
479
+ }: {
480
+ day: CalendarDay;
481
+ lookup: CalendarLookup;
482
+ density: AgendaDensity;
483
+ today: string;
484
+ selectedEventId: string;
485
+ onSelectEvent: (eventId: string) => void;
486
+ onPickSlot: (pick: CalendarSlotPick) => void;
487
+ onZoomDay: (date: string) => void;
488
+ touch?: boolean;
489
+ }) {
490
+ const isToday = day.date === today;
491
+ const groups = groupOverlapping(day.timed);
492
+ const free = freeStretchesOn(day).filter((stretch) => !stretch.wholeDay);
493
+
494
+ const items: {
495
+ key: string;
496
+ at: number;
497
+ node: ReactNode;
498
+ }[] = [
499
+ ...groups.map((group) => ({
500
+ key: group[0].id,
501
+ at: minuteOfDay(group[0].start),
502
+ node:
503
+ group.length === 1 ? (
504
+ <EventLine
505
+ event={group[0]}
506
+ lookup={lookup}
507
+ density={density}
508
+ selected={group[0].id === selectedEventId}
509
+ onSelect={() => onSelectEvent(group[0].id)}
510
+ touch={touch}
511
+ />
512
+ ) : (
513
+ <ClashGroup
514
+ events={group}
515
+ lookup={lookup}
516
+ density={density}
517
+ selectedEventId={selectedEventId}
518
+ onSelectEvent={onSelectEvent}
519
+ onZoomDay={() => onZoomDay(day.date)}
520
+ touch={touch}
521
+ />
522
+ ),
523
+ })),
524
+ ...free.map((stretch) => ({
525
+ key: `free_${stretch.startMinute}`,
526
+ at: stretch.startMinute,
527
+ node: (
528
+ <FreeBand
529
+ stretch={stretch}
530
+ onPick={() =>
531
+ onPickSlot({
532
+ date: stretch.date,
533
+ startTime: formatMinute(stretch.startMinute),
534
+ endTime: formatMinute(
535
+ Math.min(stretch.startMinute + 60, stretch.endMinute),
536
+ ),
537
+ allDay: false,
538
+ })
539
+ }
540
+ touch={touch}
541
+ />
542
+ ),
543
+ })),
544
+ ].sort((a, b) => a.at - b.at);
545
+
546
+ return (
547
+ <section
548
+ className={cn(
549
+ "border-b border-line",
550
+ isToday && "border-l-2 border-l-accent bg-accent-soft/20",
551
+ )}
552
+ >
553
+ <header className="flex h-section-row items-center gap-3 px-row-inset">
554
+ <div className="flex w-16 shrink-0 items-baseline gap-1.5">
555
+ <span
556
+ className={cn(
557
+ "text-lg font-semibold tabular-nums",
558
+ isToday ? "text-accent" : "text-fg",
559
+ )}
560
+ >
561
+ {day.dayNumber}
562
+ </span>
563
+ <span className="text-2xs uppercase tracking-wider text-fg-subtle">
564
+ {day.weekdayLabel}
565
+ </span>
566
+ </div>
567
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
568
+ {summarise(day)}
569
+ </span>
570
+ <BusyBar day={day} className="hidden w-32 shrink-0 sm:block" />
571
+ <button
572
+ type="button"
573
+ onClick={() => onZoomDay(day.date)}
574
+ className={cn(
575
+ "flex shrink-0 items-center gap-0.5 rounded-md border px-2 text-2xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
576
+ touch ? "min-h-9" : "h-6",
577
+ day.conflicts.length > 0
578
+ ? "border-warning text-warning hover:bg-warning-soft"
579
+ : "border-line text-fg-subtle hover:border-line-strong hover:text-fg",
580
+ )}
581
+ >
582
+ Grid
583
+ <ChevronRight className="size-3" />
584
+ </button>
585
+ </header>
586
+
587
+ <div className="flex flex-col gap-1 pb-2">
588
+ {day.allDay.map((event) => (
589
+ <AllDayLine
590
+ key={event.id}
591
+ event={event}
592
+ lookup={lookup}
593
+ density={density}
594
+ selected={event.id === selectedEventId}
595
+ onSelect={() => onSelectEvent(event.id)}
596
+ touch={touch}
597
+ />
598
+ ))}
599
+ {isClearDay(day) ? (
600
+ <ClearDayLine day={day} onPickSlot={onPickSlot} touch={touch} />
601
+ ) : (
602
+ items.map((item) => <div key={item.key}>{item.node}</div>)
603
+ )}
604
+ </div>
605
+ </section>
606
+ );
607
+ }
608
+
609
+ function summarise(day: CalendarDay): string {
610
+ if (day.timed.length === 0) return "Nothing booked";
611
+ const parts = [
612
+ `${day.timed.length} ${day.timed.length === 1 ? "event" : "events"}`,
613
+ `${formatSpan(day.busyMinutes)} booked`,
614
+ ];
615
+ if (day.conflicts.length > 0)
616
+ parts.push(
617
+ `${day.conflicts.length} ${day.conflicts.length === 1 ? "clash" : "clashes"}`,
618
+ );
619
+ return parts.join(" · ");
620
+ }
621
+
622
+ /** Where the day's hours went, drawn to scale. The gaps are the point. */
623
+ function BusyBar({ day, className }: { day: CalendarDay; className?: string }) {
624
+ const span = DAY_END_MINUTE - DAY_START_MINUTE;
625
+ return (
626
+ <div
627
+ aria-hidden
628
+ className={cn(
629
+ "relative h-1.5 overflow-hidden rounded-full bg-surface-sunken",
630
+ className,
631
+ )}
632
+ >
633
+ {busySpansOn(day).map((busy) => {
634
+ const from = Math.max(busy.from, DAY_START_MINUTE);
635
+ const to = Math.min(busy.to, DAY_END_MINUTE);
636
+ if (to <= from) return null;
637
+ return (
638
+ <span
639
+ key={`${busy.from}-${busy.to}`}
640
+ className="absolute inset-y-0 rounded-full bg-fg-subtle"
641
+ style={{
642
+ left: `${((from - DAY_START_MINUTE) / span) * 100}%`,
643
+ width: `${((to - from) / span) * 100}%`,
644
+ }}
645
+ />
646
+ );
647
+ })}
648
+ </div>
649
+ );
650
+ }
651
+
652
+ function ClearDayLine({
653
+ day,
654
+ onPickSlot,
655
+ touch,
656
+ }: {
657
+ day: CalendarDay;
658
+ onPickSlot: (pick: CalendarSlotPick) => void;
659
+ touch?: boolean;
660
+ }) {
661
+ return (
662
+ <button
663
+ type="button"
664
+ onClick={() =>
665
+ onPickSlot({
666
+ date: day.date,
667
+ startTime: "10:00",
668
+ endTime: "11:00",
669
+ allDay: false,
670
+ })
671
+ }
672
+ className={cn(
673
+ "mx-row-inset flex items-center gap-2 rounded-md border border-dashed border-line px-2 text-left text-xs text-fg-muted outline-none transition-colors hover:border-accent hover:text-accent focus-visible:ring-2 focus-visible:ring-ring",
674
+ touch ? "min-h-11" : "h-8",
675
+ )}
676
+ >
677
+ Free all day
678
+ </button>
679
+ );
680
+ }
681
+
682
+ function FreeBand({
683
+ stretch,
684
+ onPick,
685
+ touch,
686
+ }: {
687
+ stretch: FreeStretch;
688
+ onPick: () => void;
689
+ touch?: boolean;
690
+ }) {
691
+ const long = stretch.minutes >= LONG_FREE_MINUTES;
692
+ return (
693
+ <button
694
+ type="button"
695
+ onClick={onPick}
696
+ className={cn(
697
+ "ml-16 mr-row-inset flex items-center gap-2 rounded-md border border-dashed text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
698
+ touch ? "min-h-11 px-3" : "h-7 px-2",
699
+ long
700
+ ? "border-accent-2 bg-accent-2-soft/40 text-accent-2 hover:bg-accent-2-soft"
701
+ : "border-line text-fg-subtle hover:border-line-strong hover:text-fg-muted",
702
+ )}
703
+ >
704
+ <span className={cn("text-xs", long && "font-medium")}>
705
+ {formatSpan(stretch.minutes)} free
706
+ </span>
707
+ <span className="text-2xs tabular-nums opacity-80">
708
+ {formatMinute(stretch.startMinute)} – {formatMinute(stretch.endMinute)}
709
+ </span>
710
+ </button>
711
+ );
712
+ }
713
+
714
+ function chipDensity(density: AgendaDensity) {
715
+ return density === "detail" ? ("comfortable" as const) : ("compact" as const);
716
+ }
717
+
718
+ function AllDayLine({
719
+ event,
720
+ lookup,
721
+ density,
722
+ selected,
723
+ onSelect,
724
+ touch,
725
+ }: {
726
+ event: CalendarEventData;
727
+ lookup: CalendarLookup;
728
+ density: AgendaDensity;
729
+ selected: boolean;
730
+ onSelect: () => void;
731
+ touch?: boolean;
732
+ }) {
733
+ return (
734
+ <div className={cn("mx-row-inset", touch && "min-h-11")}>
735
+ <CalendarEventChip
736
+ title={event.title}
737
+ timeText=""
738
+ color={lookup.color(event.calendarId)}
739
+ layout="row"
740
+ density={chipDensity(density)}
741
+ rsvp={event.myRsvp}
742
+ status={event.status}
743
+ hasThread={event.threadId !== ""}
744
+ isRecurring={event.recurrenceRule !== ""}
745
+ zoneCertainty={event.zoneCertainty}
746
+ selected={selected}
747
+ onClick={onSelect}
748
+ trailing={<span className="text-2xs">All day</span>}
749
+ />
750
+ </div>
751
+ );
752
+ }
753
+
754
+ /**
755
+ * Events that run into each other, kept together and named as a pile-up. This
756
+ * is the one thing a list genuinely cannot draw, so it says so and offers the
757
+ * grid rather than pretending the rows are the whole truth.
758
+ */
759
+ function ClashGroup({
760
+ events,
761
+ lookup,
762
+ density,
763
+ selectedEventId,
764
+ onSelectEvent,
765
+ onZoomDay,
766
+ touch,
767
+ }: {
768
+ events: CalendarEventData[];
769
+ lookup: CalendarLookup;
770
+ density: AgendaDensity;
771
+ selectedEventId: string;
772
+ onSelectEvent: (eventId: string) => void;
773
+ onZoomDay: () => void;
774
+ touch?: boolean;
775
+ }) {
776
+ const from = Math.min(...events.map((event) => minuteOfDay(event.start)));
777
+ const to = Math.max(...events.map((event) => minuteOfDay(event.end)));
778
+ return (
779
+ <div className="mx-row-inset rounded-md border border-warning/50 bg-warning-soft/30">
780
+ <div className="flex items-center gap-2 px-2 py-1">
781
+ <Layers className="size-3 shrink-0 text-warning" />
782
+ <span className="text-2xs font-medium text-warning">
783
+ {events.length} at once · {formatMinute(from)} – {formatMinute(to)}
784
+ </span>
785
+ <button
786
+ type="button"
787
+ onClick={onZoomDay}
788
+ className={cn(
789
+ "ml-auto rounded-sm px-1.5 text-2xs font-medium text-warning underline underline-offset-2 outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
790
+ touch && "min-h-9",
791
+ )}
792
+ >
793
+ Open the grid
794
+ </button>
795
+ </div>
796
+ <div className="flex flex-col gap-0.5 pb-1">
797
+ {events.map((event) => (
798
+ <EventLine
799
+ key={event.id}
800
+ event={event}
801
+ lookup={lookup}
802
+ density={density}
803
+ selected={event.id === selectedEventId}
804
+ onSelect={() => onSelectEvent(event.id)}
805
+ inset={false}
806
+ touch={touch}
807
+ />
808
+ ))}
809
+ </div>
810
+ </div>
811
+ );
812
+ }
813
+
814
+ function EventLine({
815
+ event,
816
+ lookup,
817
+ density,
818
+ selected,
819
+ onSelect,
820
+ inset = true,
821
+ touch,
822
+ }: {
823
+ event: CalendarEventData;
824
+ lookup: CalendarLookup;
825
+ density: AgendaDensity;
826
+ selected: boolean;
827
+ onSelect: () => void;
828
+ inset?: boolean;
829
+ touch?: boolean;
830
+ }) {
831
+ const detailed = density === "detail";
832
+ const minutes = minuteOfDay(event.end) - minuteOfDay(event.start);
833
+ const second = [lookup.name(event.calendarId), event.location]
834
+ .filter((part) => part !== "")
835
+ .join(" · ");
836
+
837
+ return (
838
+ <div className={cn(inset ? "px-row-inset" : "px-2", touch && "min-h-12")}>
839
+ <CalendarEventChip
840
+ title={event.title}
841
+ timeText=""
842
+ color={lookup.color(event.calendarId)}
843
+ layout="row"
844
+ density={chipDensity(density)}
845
+ rsvp={event.myRsvp}
846
+ status={event.status}
847
+ hasThread={event.threadId !== ""}
848
+ isRecurring={event.recurrenceRule !== ""}
849
+ zoneCertainty={event.zoneCertainty}
850
+ selected={selected}
851
+ onClick={onSelect}
852
+ leading={
853
+ <span
854
+ className={cn(
855
+ "shrink-0 pt-1 text-2xs tabular-nums text-fg-subtle",
856
+ inset ? "w-14" : "w-11",
857
+ )}
858
+ >
859
+ {event.start.slice(11, 16)}
860
+ {detailed && (
861
+ <span className="block opacity-70">
862
+ {event.end.slice(11, 16)}
863
+ </span>
864
+ )}
865
+ </span>
866
+ }
867
+ trailing={
868
+ <span className="text-2xs tabular-nums">{formatSpan(minutes)}</span>
869
+ }
870
+ detail={
871
+ detailed && second !== "" ? (
872
+ <span className="flex min-w-0 items-center gap-2 text-2xs opacity-80">
873
+ {event.location !== "" && <MapPin className="size-3 shrink-0" />}
874
+ <span className="truncate">{second}</span>
875
+ {event.attendees.length > 0 && (
876
+ <span className="ml-auto flex shrink-0 items-center gap-0.5">
877
+ <Users className="size-3" />
878
+ {event.attendees.length}
879
+ </span>
880
+ )}
881
+ </span>
882
+ ) : undefined
883
+ }
884
+ />
885
+ </div>
886
+ );
887
+ }