@remit/ui 0.0.155 → 0.0.157
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.
- package/package.json +5 -2
- package/src/calendar.css +4 -0
- package/src/components/agenda-composer.render.test.ts +196 -0
- package/src/components/agenda-composer.stories.tsx +225 -0
- package/src/components/agenda-composer.tsx +283 -0
- package/src/components/agenda-flow.render.test.ts +217 -0
- package/src/components/agenda-flow.stories.tsx +215 -0
- package/src/components/agenda-flow.tsx +887 -0
- package/src/components/agenda-panels.render.test.ts +252 -0
- package/src/components/agenda-panels.stories.tsx +207 -0
- package/src/components/agenda-panels.tsx +421 -0
- package/src/components/calendar-event-chip-content.tsx +102 -0
- package/src/components/calendar-event-chip.stories.tsx +5 -7
- package/src/components/calendar-event-chip.tsx +47 -47
- package/src/components/calendar-grid.render.test.ts +309 -0
- package/src/components/calendar-grid.stories.tsx +203 -0
- package/src/components/calendar-grid.tsx +370 -0
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +82 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
- package/src/lib/calendar-event-shell.ts +55 -0
- package/src/lib/calendar-slot-pick.test.ts +140 -0
- package/src/lib/calendar-slot-pick.ts +65 -0
|
@@ -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
|
+
}
|