@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.
- package/package.json +2 -1
- 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.tsx +86 -37
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +64 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readings that sit beside the strip.
|
|
3
|
+
*
|
|
4
|
+
* A wide screen given to a single column of days wastes it as badly as a grid
|
|
5
|
+
* wastes a sparse week, so the width goes to the questions the list is good at:
|
|
6
|
+
* what is next, where the free time is, and where in the strip you currently
|
|
7
|
+
* are. None of them is a second copy of the list — each one answers something
|
|
8
|
+
* the rows cannot answer at a glance.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LucideIcon } from "lucide-react";
|
|
12
|
+
import {
|
|
13
|
+
AlignJustify,
|
|
14
|
+
CalendarOff,
|
|
15
|
+
Clock,
|
|
16
|
+
LayoutList,
|
|
17
|
+
MoreHorizontal,
|
|
18
|
+
Radio,
|
|
19
|
+
Sun,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { type ReactNode, useId, useMemo } from "react";
|
|
22
|
+
import {
|
|
23
|
+
addDays,
|
|
24
|
+
DAY_END_MINUTE,
|
|
25
|
+
DAY_START_MINUTE,
|
|
26
|
+
datesBetween,
|
|
27
|
+
type FreeStretch,
|
|
28
|
+
formatMinute,
|
|
29
|
+
formatShortDay,
|
|
30
|
+
formatSpan,
|
|
31
|
+
type NextUp,
|
|
32
|
+
shortMonthLabel,
|
|
33
|
+
} from "../lib/agenda-time.js";
|
|
34
|
+
import { calendarColorClasses } from "../lib/calendar-color.js";
|
|
35
|
+
import { cn } from "../lib/cn.js";
|
|
36
|
+
import type { AgendaDensity } from "./agenda-flow.js";
|
|
37
|
+
import { segmentClassName } from "./calendar-toolbar.js";
|
|
38
|
+
import type {
|
|
39
|
+
CalendarColorId,
|
|
40
|
+
CalendarDay,
|
|
41
|
+
CalendarDescriptor,
|
|
42
|
+
} from "./calendar-types.js";
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------------ */
|
|
45
|
+
/* Density */
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
|
|
48
|
+
const DENSITY_OPTIONS: {
|
|
49
|
+
value: AgendaDensity;
|
|
50
|
+
label: string;
|
|
51
|
+
Icon: LucideIcon;
|
|
52
|
+
}[] = [
|
|
53
|
+
{ value: "dots", label: "Dots", Icon: MoreHorizontal },
|
|
54
|
+
{ value: "pills", label: "Rows", Icon: AlignJustify },
|
|
55
|
+
{ value: "detail", label: "Detail", Icon: LayoutList },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export interface AgendaDensityControlProps {
|
|
59
|
+
value: AgendaDensity;
|
|
60
|
+
onChange: (density: AgendaDensity) => void;
|
|
61
|
+
/** Where the words do not fit — a phone's bottom bar. */
|
|
62
|
+
icons?: boolean;
|
|
63
|
+
touch?: boolean;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Three steps, not two. A list can be read as a month of coloured dots or as a
|
|
69
|
+
* stack of cards, and which one is right depends on the day and the person, so
|
|
70
|
+
* the control is on screen at every width rather than in a settings page.
|
|
71
|
+
*/
|
|
72
|
+
export function AgendaDensityControl({
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
icons,
|
|
76
|
+
touch,
|
|
77
|
+
className,
|
|
78
|
+
}: AgendaDensityControlProps) {
|
|
79
|
+
const group = useId();
|
|
80
|
+
return (
|
|
81
|
+
<fieldset className={cn("inline-flex items-center gap-0.5", className)}>
|
|
82
|
+
<legend className="sr-only">Agenda density</legend>
|
|
83
|
+
{DENSITY_OPTIONS.map((option) => (
|
|
84
|
+
<label
|
|
85
|
+
key={option.value}
|
|
86
|
+
aria-label={option.label}
|
|
87
|
+
className={cn(
|
|
88
|
+
segmentClassName(value === option.value),
|
|
89
|
+
touch ? "min-h-11 flex-1 text-sm" : "h-7 text-xs",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<input
|
|
93
|
+
type="radio"
|
|
94
|
+
name={group}
|
|
95
|
+
value={option.value}
|
|
96
|
+
checked={value === option.value}
|
|
97
|
+
onChange={() => onChange(option.value)}
|
|
98
|
+
className="sr-only"
|
|
99
|
+
/>
|
|
100
|
+
{icons ? <option.Icon className="size-4" /> : option.label}
|
|
101
|
+
</label>
|
|
102
|
+
))}
|
|
103
|
+
</fieldset>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* What's next */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
|
|
111
|
+
export interface NextUpCardProps {
|
|
112
|
+
nextUp: NextUp;
|
|
113
|
+
/** Whose hue each named event is drawn with. */
|
|
114
|
+
calendars: readonly CalendarDescriptor[];
|
|
115
|
+
today: string;
|
|
116
|
+
onSelectEvent: (eventId: string) => void;
|
|
117
|
+
onGoTo: (date: string) => void;
|
|
118
|
+
touch?: boolean;
|
|
119
|
+
className?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The one question a grid answers badly. "What is next" out of a time grid
|
|
124
|
+
* means finding the now-line and reading downward past the empty rows; here it
|
|
125
|
+
* is a sentence, and the free time after it is part of the same sentence.
|
|
126
|
+
*/
|
|
127
|
+
export function NextUpCard({
|
|
128
|
+
nextUp,
|
|
129
|
+
calendars,
|
|
130
|
+
today,
|
|
131
|
+
onSelectEvent,
|
|
132
|
+
onGoTo,
|
|
133
|
+
touch,
|
|
134
|
+
className,
|
|
135
|
+
}: NextUpCardProps) {
|
|
136
|
+
const { running, next, minutesUntilNext, after, free } = nextUp;
|
|
137
|
+
const colorOf = useMemo(() => {
|
|
138
|
+
const byId = new Map(
|
|
139
|
+
calendars.map((calendar) => [calendar.id, calendar.color]),
|
|
140
|
+
);
|
|
141
|
+
return (calendarId: string): CalendarColorId =>
|
|
142
|
+
byId.get(calendarId) ?? "cal-1";
|
|
143
|
+
}, [calendars]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<section
|
|
147
|
+
className={cn(
|
|
148
|
+
"flex flex-col gap-2 rounded-lg border border-line bg-surface-raised p-3",
|
|
149
|
+
className,
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
{running.length > 0 && (
|
|
153
|
+
<div className="flex flex-col gap-1">
|
|
154
|
+
<Caption icon={<Radio className="size-3 text-danger" />}>Now</Caption>
|
|
155
|
+
{running.map((event) => (
|
|
156
|
+
<EventButton
|
|
157
|
+
key={event.id}
|
|
158
|
+
eventId={event.id}
|
|
159
|
+
title={event.title}
|
|
160
|
+
color={colorOf(event.calendarId)}
|
|
161
|
+
meta={`until ${event.end.slice(11, 16)}`}
|
|
162
|
+
onSelect={onSelectEvent}
|
|
163
|
+
touch={touch}
|
|
164
|
+
/>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<div className="flex flex-col gap-1">
|
|
170
|
+
<Caption icon={<Clock className="size-3" />}>
|
|
171
|
+
{next ? `Next · in ${formatSpan(minutesUntilNext)}` : "Next"}
|
|
172
|
+
</Caption>
|
|
173
|
+
{next ? (
|
|
174
|
+
<EventButton
|
|
175
|
+
eventId={next.id}
|
|
176
|
+
title={next.title}
|
|
177
|
+
color={colorOf(next.calendarId)}
|
|
178
|
+
meta={`${dayPrefix(next.start.slice(0, 10), today)}${next.start.slice(
|
|
179
|
+
11,
|
|
180
|
+
16,
|
|
181
|
+
)}${next.location === "" ? "" : ` · ${next.location}`}`}
|
|
182
|
+
onSelect={onSelectEvent}
|
|
183
|
+
touch={touch}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<p className="text-sm text-fg-muted">Nothing else booked.</p>
|
|
187
|
+
)}
|
|
188
|
+
{after && (
|
|
189
|
+
<p className="truncate pl-1 text-2xs text-fg-subtle">
|
|
190
|
+
then {after.title} · {dayPrefix(after.start.slice(0, 10), today)}
|
|
191
|
+
{after.start.slice(11, 16)}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{free && (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => onGoTo(free.date)}
|
|
200
|
+
className={cn(
|
|
201
|
+
"flex items-center gap-2 rounded-md border border-dashed border-accent-2 bg-accent-2-soft/40 px-2 text-left text-accent-2 outline-none transition-colors hover:bg-accent-2-soft focus-visible:ring-2 focus-visible:ring-ring",
|
|
202
|
+
touch ? "min-h-11" : "min-h-8",
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
<Sun className="size-3.5 shrink-0" />
|
|
206
|
+
<span className="text-xs font-medium">
|
|
207
|
+
{formatSpan(free.minutes)} free
|
|
208
|
+
</span>
|
|
209
|
+
<span className="text-2xs tabular-nums opacity-80">
|
|
210
|
+
{dayPrefix(free.date, today)}
|
|
211
|
+
{formatMinute(free.startMinute)} – {formatMinute(free.endMinute)}
|
|
212
|
+
</span>
|
|
213
|
+
</button>
|
|
214
|
+
)}
|
|
215
|
+
</section>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Caption({ icon, children }: { icon: ReactNode; children: ReactNode }) {
|
|
220
|
+
return (
|
|
221
|
+
<h3 className="flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
222
|
+
{icon}
|
|
223
|
+
{children}
|
|
224
|
+
</h3>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function EventButton({
|
|
229
|
+
eventId,
|
|
230
|
+
title,
|
|
231
|
+
color,
|
|
232
|
+
meta,
|
|
233
|
+
onSelect,
|
|
234
|
+
touch,
|
|
235
|
+
}: {
|
|
236
|
+
eventId: string;
|
|
237
|
+
title: string;
|
|
238
|
+
color: CalendarColorId;
|
|
239
|
+
meta: string;
|
|
240
|
+
onSelect: (eventId: string) => void;
|
|
241
|
+
touch?: boolean;
|
|
242
|
+
}) {
|
|
243
|
+
const hue = calendarColorClasses(color);
|
|
244
|
+
return (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => onSelect(eventId)}
|
|
248
|
+
className={cn(
|
|
249
|
+
"flex w-full items-center gap-2 rounded-md border-l-2 px-2 py-1 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
250
|
+
hue.soft,
|
|
251
|
+
hue.text,
|
|
252
|
+
hue.rail,
|
|
253
|
+
touch && "min-h-12",
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
<span className="min-w-0 flex-1">
|
|
257
|
+
<span className="block truncate text-sm font-medium">{title}</span>
|
|
258
|
+
<span className="block truncate text-2xs opacity-80">{meta}</span>
|
|
259
|
+
</span>
|
|
260
|
+
</button>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function dayPrefix(date: string, today: string): string {
|
|
265
|
+
if (date === today) return "";
|
|
266
|
+
if (date === addDays(today, 1)) return "tomorrow · ";
|
|
267
|
+
return `${formatShortDay(date)} · `;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ------------------------------------------------------------------ */
|
|
271
|
+
/* Free time */
|
|
272
|
+
/* ------------------------------------------------------------------ */
|
|
273
|
+
|
|
274
|
+
export interface FreeTimeListProps {
|
|
275
|
+
stretches: FreeStretch[];
|
|
276
|
+
today: string;
|
|
277
|
+
onPick: (stretch: FreeStretch) => void;
|
|
278
|
+
touch?: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Empty time, listed like anything else that is on the calendar. */
|
|
282
|
+
export function FreeTimeList({
|
|
283
|
+
stretches,
|
|
284
|
+
today,
|
|
285
|
+
onPick,
|
|
286
|
+
touch,
|
|
287
|
+
}: FreeTimeListProps) {
|
|
288
|
+
return (
|
|
289
|
+
<section className="flex flex-col gap-1.5">
|
|
290
|
+
<Caption icon={<CalendarOff className="size-3" />}>Open time</Caption>
|
|
291
|
+
{stretches.length === 0 ? (
|
|
292
|
+
<p className="text-xs text-fg-subtle">
|
|
293
|
+
Nothing open in the days on screen.
|
|
294
|
+
</p>
|
|
295
|
+
) : (
|
|
296
|
+
stretches.map((stretch) => (
|
|
297
|
+
<button
|
|
298
|
+
key={`${stretch.date}_${stretch.startMinute}`}
|
|
299
|
+
type="button"
|
|
300
|
+
onClick={() => onPick(stretch)}
|
|
301
|
+
className={cn(
|
|
302
|
+
"flex items-center gap-2 rounded-md border border-line px-2 text-left outline-none transition-colors hover:border-accent hover:text-accent focus-visible:ring-2 focus-visible:ring-ring",
|
|
303
|
+
touch ? "min-h-11" : "min-h-8",
|
|
304
|
+
)}
|
|
305
|
+
>
|
|
306
|
+
<span className="text-xs font-medium text-fg">
|
|
307
|
+
{formatSpan(stretch.minutes)}
|
|
308
|
+
</span>
|
|
309
|
+
<span className="min-w-0 flex-1 truncate text-2xs text-fg-subtle">
|
|
310
|
+
{stretch.date === today ? "today" : formatShortDay(stretch.date)}{" "}
|
|
311
|
+
· {formatMinute(stretch.startMinute)} –{" "}
|
|
312
|
+
{formatMinute(stretch.endMinute)}
|
|
313
|
+
</span>
|
|
314
|
+
</button>
|
|
315
|
+
))
|
|
316
|
+
)}
|
|
317
|
+
</section>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------------------ */
|
|
322
|
+
/* Where you are in the strip */
|
|
323
|
+
/* ------------------------------------------------------------------ */
|
|
324
|
+
|
|
325
|
+
export interface PositionMapProps {
|
|
326
|
+
/** The month to draw, and the one after it. */
|
|
327
|
+
anchorDate: string;
|
|
328
|
+
visibleDate: string;
|
|
329
|
+
today: string;
|
|
330
|
+
dayOf: (date: string) => CalendarDay;
|
|
331
|
+
onGoTo: (date: string) => void;
|
|
332
|
+
className?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Two months as a heat strip: how full each day is, and where the strip is
|
|
337
|
+
* currently parked. It is a scrollbar with meaning — the empty days are as
|
|
338
|
+
* legible as the full ones, which is the whole argument in miniature.
|
|
339
|
+
*/
|
|
340
|
+
export function PositionMap({
|
|
341
|
+
anchorDate,
|
|
342
|
+
visibleDate,
|
|
343
|
+
today,
|
|
344
|
+
dayOf,
|
|
345
|
+
onGoTo,
|
|
346
|
+
className,
|
|
347
|
+
}: PositionMapProps) {
|
|
348
|
+
const months = [
|
|
349
|
+
monthStart(anchorDate),
|
|
350
|
+
monthStart(addDays(monthEnd(anchorDate), 1)),
|
|
351
|
+
];
|
|
352
|
+
const span = DAY_END_MINUTE - DAY_START_MINUTE;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div className={cn("flex flex-col gap-3", className)}>
|
|
356
|
+
{months.map((first) => (
|
|
357
|
+
<section key={first} className="flex flex-col gap-1">
|
|
358
|
+
<h3 className="px-row-inset text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
359
|
+
{shortMonthLabel(first)}
|
|
360
|
+
</h3>
|
|
361
|
+
<div className="grid grid-cols-7 gap-0.5 px-row-inset">
|
|
362
|
+
{leadingBlanks(first).map((key) => (
|
|
363
|
+
<span key={key} />
|
|
364
|
+
))}
|
|
365
|
+
{datesBetween(first, monthEnd(first)).map((date) => {
|
|
366
|
+
const day = dayOf(date);
|
|
367
|
+
const fill = Math.min(1, day.busyMinutes / span);
|
|
368
|
+
return (
|
|
369
|
+
<button
|
|
370
|
+
key={date}
|
|
371
|
+
type="button"
|
|
372
|
+
onClick={() => onGoTo(date)}
|
|
373
|
+
className={cn(
|
|
374
|
+
"relative flex h-6 items-center justify-center rounded-sm text-2xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
375
|
+
date === visibleDate
|
|
376
|
+
? "bg-surface-raised font-semibold text-fg ring-1 ring-line-strong"
|
|
377
|
+
: "text-fg-muted hover:bg-surface-sunken",
|
|
378
|
+
date === today && "font-semibold text-accent",
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
{Number(date.slice(8))}
|
|
382
|
+
{fill > 0 && (
|
|
383
|
+
<span
|
|
384
|
+
aria-hidden
|
|
385
|
+
className="absolute inset-x-1 bottom-0.5 h-0.5 rounded-full bg-fg-subtle"
|
|
386
|
+
style={{ opacity: 0.35 + fill * 0.65 }}
|
|
387
|
+
/>
|
|
388
|
+
)}
|
|
389
|
+
{day.conflicts.length > 0 && (
|
|
390
|
+
<span
|
|
391
|
+
aria-hidden
|
|
392
|
+
className="absolute right-0.5 top-0.5 size-1 rounded-full bg-warning"
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
</section>
|
|
400
|
+
))}
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function monthStart(date: string): string {
|
|
406
|
+
return `${date.slice(0, 8)}01`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function monthEnd(date: string): string {
|
|
410
|
+
const [year, month] = date.split("-").map(Number);
|
|
411
|
+
const last = new Date(Date.UTC(year, month, 0));
|
|
412
|
+
return last.toISOString().slice(0, 10);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Monday-first padding cells before the first of the month. */
|
|
416
|
+
function leadingBlanks(first: string): string[] {
|
|
417
|
+
const [year, month, day] = first.split("-").map(Number);
|
|
418
|
+
const weekday = new Date(year, month - 1, day).getDay();
|
|
419
|
+
const count = (weekday + 6) % 7;
|
|
420
|
+
return Array.from({ length: count }, (_, index) => `blank_${first}_${index}`);
|
|
421
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Globe, Mail, Repeat } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import { calendarColorClasses } from "../lib/calendar-color.js";
|
|
3
4
|
import { cn } from "../lib/cn.js";
|
|
4
5
|
import type { Density } from "./app-shell-types.js";
|
|
@@ -27,6 +28,16 @@ export interface CalendarEventChipProps {
|
|
|
27
28
|
zoneCertainty: ZoneCertainty;
|
|
28
29
|
selected: boolean;
|
|
29
30
|
onClick?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Rendered inside the control but outside the coloured body, ahead of it —
|
|
33
|
+
* the agenda's time gutter, which has to line up down a whole day and so
|
|
34
|
+
* cannot sit inside a block whose width follows the title.
|
|
35
|
+
*/
|
|
36
|
+
leading?: ReactNode;
|
|
37
|
+
/** Rendered at the far end of the title line — a length, a count. */
|
|
38
|
+
trailing?: ReactNode;
|
|
39
|
+
/** A second line under the title: where it is, who is coming, whose calendar. */
|
|
40
|
+
detail?: ReactNode;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
/**
|
|
@@ -54,27 +65,63 @@ export function CalendarEventChip({
|
|
|
54
65
|
zoneCertainty,
|
|
55
66
|
selected,
|
|
56
67
|
onClick,
|
|
68
|
+
leading,
|
|
69
|
+
trailing,
|
|
70
|
+
detail,
|
|
57
71
|
}: CalendarEventChipProps) {
|
|
58
72
|
const hue = calendarColorClasses(color);
|
|
59
73
|
const isColumn = layout === "column";
|
|
60
74
|
const isCompact = density === "compact";
|
|
61
75
|
const declined = rsvp === "declined";
|
|
62
76
|
const provisional = rsvp === "tentative" || status === "tentative";
|
|
77
|
+
const stacked = isColumn || detail !== undefined;
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
type="button"
|
|
67
|
-
onClick={onClick}
|
|
68
|
-
aria-pressed={selected}
|
|
79
|
+
const head = (
|
|
80
|
+
<span
|
|
69
81
|
className={cn(
|
|
70
|
-
"
|
|
71
|
-
|
|
82
|
+
"flex min-w-0 items-center gap-1",
|
|
83
|
+
isColumn && "w-full",
|
|
84
|
+
declined && "line-through",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{timeText !== "" && (
|
|
88
|
+
<span className="shrink-0 tabular-nums opacity-80">{timeText}</span>
|
|
89
|
+
)}
|
|
90
|
+
<span className="truncate font-medium">{title}</span>
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const marks = (hasThread || isRecurring || zoneCertainty === "ambiguous") && (
|
|
95
|
+
<span
|
|
96
|
+
className={cn(
|
|
97
|
+
"flex shrink-0 items-center gap-1",
|
|
98
|
+
isColumn && "mt-0.5",
|
|
99
|
+
!isColumn && trailing === undefined && "ml-auto",
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
|
|
103
|
+
{hasThread && <Mail className="size-2.5" aria-label="From mail" />}
|
|
104
|
+
{zoneCertainty === "ambiguous" && (
|
|
105
|
+
<Globe className="size-2.5 text-warning" aria-label="Unclear zone" />
|
|
106
|
+
)}
|
|
107
|
+
</span>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const tail = trailing !== undefined && (
|
|
111
|
+
<span className={cn("shrink-0 opacity-80", !isColumn && "ml-auto")}>
|
|
112
|
+
{trailing}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const body = (
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
"flex min-w-0 flex-1 overflow-hidden rounded-sm border-l-2 px-1.5 py-0.5",
|
|
72
120
|
hue.soft,
|
|
73
121
|
hue.text,
|
|
74
|
-
isColumn
|
|
75
|
-
? "h-full flex-col rounded-sm border-l-2 px-1.5 py-0.5"
|
|
76
|
-
: "items-center gap-1.5 rounded-sm border-l-2 px-1.5 py-0.5",
|
|
77
122
|
hue.rail,
|
|
123
|
+
isColumn && "h-full",
|
|
124
|
+
stacked ? "flex-col" : "items-center gap-1.5",
|
|
78
125
|
provisional && "border-y border-r border-dashed",
|
|
79
126
|
provisional && hue.border,
|
|
80
127
|
declined && "opacity-60",
|
|
@@ -82,35 +129,37 @@ export function CalendarEventChip({
|
|
|
82
129
|
isCompact ? "text-2xs" : "text-xs",
|
|
83
130
|
)}
|
|
84
131
|
>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
</span>
|
|
97
|
-
{(hasThread || isRecurring || zoneCertainty === "ambiguous") && (
|
|
98
|
-
<span
|
|
99
|
-
className={cn(
|
|
100
|
-
"flex shrink-0 items-center gap-1",
|
|
101
|
-
isColumn ? "mt-0.5" : "ml-auto",
|
|
102
|
-
)}
|
|
103
|
-
>
|
|
104
|
-
{isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
|
|
105
|
-
{hasThread && <Mail className="size-2.5" aria-label="From mail" />}
|
|
106
|
-
{zoneCertainty === "ambiguous" && (
|
|
107
|
-
<Globe
|
|
108
|
-
className="size-2.5 text-warning"
|
|
109
|
-
aria-label="Unclear zone"
|
|
110
|
-
/>
|
|
111
|
-
)}
|
|
132
|
+
{isColumn || detail === undefined ? (
|
|
133
|
+
<>
|
|
134
|
+
{head}
|
|
135
|
+
{marks}
|
|
136
|
+
{tail}
|
|
137
|
+
</>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
140
|
+
{head}
|
|
141
|
+
{marks}
|
|
142
|
+
{tail}
|
|
112
143
|
</span>
|
|
113
144
|
)}
|
|
145
|
+
{detail}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={onClick}
|
|
153
|
+
aria-pressed={selected}
|
|
154
|
+
className={cn(
|
|
155
|
+
"group flex w-full min-w-0 text-left outline-none transition-colors",
|
|
156
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
|
|
157
|
+
isColumn ? "h-full" : "items-start",
|
|
158
|
+
leading !== undefined && "gap-2",
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{leading}
|
|
162
|
+
{body}
|
|
114
163
|
</button>
|
|
115
164
|
);
|
|
116
165
|
}
|
|
@@ -82,6 +82,91 @@ export interface CalendarEventData {
|
|
|
82
82
|
status: "confirmed" | "tentative";
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One day, with everything on it already sorted and measured. The surfaces that
|
|
87
|
+
* render a day take this rather than a flat event list, so the arithmetic is
|
|
88
|
+
* done once by whoever owns the events.
|
|
89
|
+
*/
|
|
90
|
+
export interface CalendarDay {
|
|
91
|
+
/** `YYYY-MM-DD`. */
|
|
92
|
+
date: string;
|
|
93
|
+
weekdayLabel: string;
|
|
94
|
+
dayNumber: number;
|
|
95
|
+
isToday: boolean;
|
|
96
|
+
/** Ascending by start. */
|
|
97
|
+
timed: CalendarEventData[];
|
|
98
|
+
allDay: CalendarEventData[];
|
|
99
|
+
/** Minutes of the day covered by at least one timed event. */
|
|
100
|
+
busyMinutes: number;
|
|
101
|
+
/**
|
|
102
|
+
* Every pile-up on the day: one group per event that something else runs
|
|
103
|
+
* into, holding that event and everything overlapping it. Members all meet
|
|
104
|
+
* the event the group is built around, not necessarily each other — which is
|
|
105
|
+
* what a grid has to lay out. Empty when the day is clean.
|
|
106
|
+
*/
|
|
107
|
+
conflicts: string[][];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An empty slot a reader picked, wherever they picked it — a grid, a list. */
|
|
111
|
+
export interface CalendarSlotPick {
|
|
112
|
+
/** `YYYY-MM-DD`. */
|
|
113
|
+
date: string;
|
|
114
|
+
/** `HH:MM`, empty when the pick landed in the all-day band. */
|
|
115
|
+
startTime: string;
|
|
116
|
+
endTime: string;
|
|
117
|
+
allDay: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One reading of a phrase the parser could not settle on its own. */
|
|
121
|
+
export interface PhraseChoiceOption {
|
|
122
|
+
id: string;
|
|
123
|
+
label: string;
|
|
124
|
+
/** Empty leaves the reading from the rest of the sentence alone. */
|
|
125
|
+
date: string;
|
|
126
|
+
startTime: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface PhraseChoice {
|
|
130
|
+
id: string;
|
|
131
|
+
question: string;
|
|
132
|
+
/** The words the question is about. */
|
|
133
|
+
source: string;
|
|
134
|
+
options: PhraseChoiceOption[];
|
|
135
|
+
chosenId: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a reader made of a typed sentence, with the words each reading came
|
|
140
|
+
* from. Every field is presentational: the composer renders it and never parses
|
|
141
|
+
* anything itself, so a parser can be swapped without touching the surface.
|
|
142
|
+
*/
|
|
143
|
+
export interface AgendaParse {
|
|
144
|
+
title: string;
|
|
145
|
+
date: string;
|
|
146
|
+
dateText: string;
|
|
147
|
+
startTime: string;
|
|
148
|
+
startTimeText: string;
|
|
149
|
+
endTime: string;
|
|
150
|
+
durationMinutes: number;
|
|
151
|
+
durationText: string;
|
|
152
|
+
attendees: string[];
|
|
153
|
+
attendeesText: string;
|
|
154
|
+
location: string;
|
|
155
|
+
locationText: string;
|
|
156
|
+
/** Human-readable rule, empty for a one-off. */
|
|
157
|
+
repeat: string;
|
|
158
|
+
repeatText: string;
|
|
159
|
+
/** Defaults the reader applied on its own authority. */
|
|
160
|
+
assumptions: string[];
|
|
161
|
+
/** What the sentence never said. */
|
|
162
|
+
unresolved: string[];
|
|
163
|
+
/** What the sentence said two ways. */
|
|
164
|
+
choices: PhraseChoice[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Choice id → option id, as answered by the person typing. */
|
|
168
|
+
export type ChoicePicks = Readonly<Record<string, string>>;
|
|
169
|
+
|
|
85
170
|
/**
|
|
86
171
|
* One clock a zoneless time could be on. Present only when the source genuinely
|
|
87
172
|
* did not say which, and the reader is the one who settles it.
|