@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.
@@ -0,0 +1,370 @@
1
+ import type {
2
+ CalendarRef,
3
+ EventDisplayInfo,
4
+ EventInput,
5
+ } from "@fullcalendar/react";
6
+ import FullCalendar from "@fullcalendar/react";
7
+ import dayGridPlugin from "@fullcalendar/react/daygrid";
8
+ import interactionPlugin from "@fullcalendar/react/interaction";
9
+ import listPlugin from "@fullcalendar/react/list";
10
+ import multiMonthPlugin from "@fullcalendar/react/multimonth";
11
+ import timeGridPlugin from "@fullcalendar/react/timegrid";
12
+ import { useEffect, useMemo, useRef } from "react";
13
+ import { calendarEventBodyClasses } from "../lib/calendar-event-shell.js";
14
+ import {
15
+ isDraggedSelection,
16
+ pointPick,
17
+ rangePick,
18
+ } from "../lib/calendar-slot-pick.js";
19
+ import { cn } from "../lib/cn.js";
20
+ import type { Density } from "./app-shell-types.js";
21
+ import { CalendarEventChipContent } from "./calendar-event-chip-content.js";
22
+ import type {
23
+ CalendarColorId,
24
+ CalendarEventData,
25
+ CalendarSlotPick,
26
+ CalendarViewId,
27
+ RsvpState,
28
+ ZoneCertainty,
29
+ } from "./calendar-types.js";
30
+
31
+ /**
32
+ * One continuous strip of time, at whichever zoom the view names. Every pixel
33
+ * comes from the kit's tokens: the calendar engine ships a structural skeleton
34
+ * sheet and no theme, and it is styled entirely through its per-element
35
+ * `*Class` props, so no rule here overrides one of the library's own.
36
+ *
37
+ * The component is presentational. It holds no events of its own, reads no
38
+ * clock of its own, and every gesture leaves through a callback.
39
+ */
40
+
41
+ const PLUGINS = [
42
+ dayGridPlugin,
43
+ timeGridPlugin,
44
+ listPlugin,
45
+ multiMonthPlugin,
46
+ interactionPlugin,
47
+ ];
48
+
49
+ const FC_VIEW: Record<CalendarViewId, string> = {
50
+ year: "multiMonthYear",
51
+ month: "dayGridMonth",
52
+ week: "timeGridWeek",
53
+ day: "timeGridDay",
54
+ agenda: "listWeek",
55
+ };
56
+
57
+ /* A week or a day names the date in its header. A month grid repeats the same
58
+ seven weekdays down the page, so the header names the weekday and the cells
59
+ own the dates; a year has room for one letter. */
60
+ const DAY_HEADER_FORMAT: Record<
61
+ CalendarViewId,
62
+ { weekday: "short" | "narrow"; day?: "numeric" }
63
+ > = {
64
+ year: { weekday: "narrow" },
65
+ month: { weekday: "short" },
66
+ week: { weekday: "short", day: "numeric" },
67
+ day: { weekday: "short", day: "numeric" },
68
+ agenda: { weekday: "short", day: "numeric" },
69
+ };
70
+
71
+ /** Views that draw every event as a horizontal pill rather than a block. */
72
+ const ROW_VIEWS = new Set<CalendarViewId>(["year", "month", "agenda"]);
73
+
74
+ /**
75
+ * Density is a real change in how much of a day fits on screen, not a padding
76
+ * tweak: a tighter setting halves the slot height and drops the time off short
77
+ * chips.
78
+ */
79
+ const DENSITY: Record<
80
+ Density,
81
+ { slotMinutes: number; slotMinHeight: number; eventShortHeight: number }
82
+ > = {
83
+ comfortable: { slotMinutes: 30, slotMinHeight: 26, eventShortHeight: 34 },
84
+ compact: { slotMinutes: 60, slotMinHeight: 15, eventShortHeight: 22 },
85
+ };
86
+
87
+ const FALLBACK_COLOR: CalendarColorId = "cal-1";
88
+
89
+ export interface CalendarGridProps {
90
+ view: CalendarViewId;
91
+ /** The day the view is centred on; changing it moves the grid. */
92
+ date: string;
93
+ events: CalendarEventData[];
94
+ /** Which calendar owns which hue. Anything missing falls back to cal-1. */
95
+ colorByCalendarId: Record<string, CalendarColorId>;
96
+ density: Density;
97
+ selectedEventId: string;
98
+ /** IANA zone the grid's clock runs on, and the zone a pick is read in. */
99
+ timeZone: string;
100
+ /** The instant the grid calls now: the today marker and the now line read it. */
101
+ now: string;
102
+ onSelectEvent: (eventId: string) => void;
103
+ onPickSlot: (pick: CalendarSlotPick) => void;
104
+ /** The range title the grid computed, e.g. "8 – 14 Jun 2026". */
105
+ onRangeChange: (title: string) => void;
106
+ className?: string;
107
+ }
108
+
109
+ /**
110
+ * What drawing one event needs, keyed by id. The engine renders the element and
111
+ * hands the callback its own `EventApi`, so the data is looked up rather than
112
+ * smuggled through `extendedProps` and cast back out.
113
+ */
114
+ interface GridEvent {
115
+ color: CalendarColorId;
116
+ rsvp: RsvpState;
117
+ status: "confirmed" | "tentative";
118
+ hasThread: boolean;
119
+ isRecurring: boolean;
120
+ zoneCertainty: ZoneCertainty;
121
+ }
122
+
123
+ /** The placeholder the engine drags under the pointer, which is not one of ours. */
124
+ const MIRROR_EVENT: GridEvent = {
125
+ color: FALLBACK_COLOR,
126
+ rsvp: "accepted",
127
+ status: "confirmed",
128
+ hasThread: false,
129
+ isRecurring: false,
130
+ zoneCertainty: "local",
131
+ };
132
+
133
+ function toInput(event: CalendarEventData): EventInput {
134
+ return {
135
+ id: event.id,
136
+ title: event.title,
137
+ start: event.start,
138
+ end: event.end,
139
+ allDay: event.allDay,
140
+ };
141
+ }
142
+
143
+ export function CalendarGrid({
144
+ view,
145
+ date,
146
+ events,
147
+ colorByCalendarId,
148
+ density,
149
+ selectedEventId,
150
+ timeZone,
151
+ now,
152
+ onSelectEvent,
153
+ onPickSlot,
154
+ onRangeChange,
155
+ className,
156
+ }: CalendarGridProps) {
157
+ const calendarRef = useRef<CalendarRef>(null);
158
+
159
+ useEffect(() => {
160
+ calendarRef.current?.getApi().changeView(FC_VIEW[view]);
161
+ }, [view]);
162
+
163
+ useEffect(() => {
164
+ calendarRef.current?.getApi().gotoDate(date);
165
+ }, [date]);
166
+
167
+ const eventInputs = useMemo(() => events.map(toInput), [events]);
168
+
169
+ const byId = useMemo(
170
+ () =>
171
+ new Map<string, GridEvent>(
172
+ events.map((event) => [
173
+ event.id,
174
+ {
175
+ color: colorByCalendarId[event.calendarId] ?? FALLBACK_COLOR,
176
+ rsvp: event.myRsvp,
177
+ status: event.status,
178
+ hasThread: event.threadId !== "",
179
+ isRecurring: event.recurrenceRule !== "",
180
+ zoneCertainty: event.zoneCertainty,
181
+ },
182
+ ]),
183
+ ),
184
+ [events, colorByCalendarId],
185
+ );
186
+
187
+ const lookup = useMemo(
188
+ () => (id: string) => byId.get(id) ?? MIRROR_EVENT,
189
+ [byId],
190
+ );
191
+
192
+ const isRowEvent = useMemo(
193
+ () => (info: EventDisplayInfo) => info.event.allDay || ROW_VIEWS.has(view),
194
+ [view],
195
+ );
196
+
197
+ /* The event element the engine built, dressed as the body of a
198
+ `CalendarEventChip`: same box, same hue, same states, one definition. A
199
+ grid cell is tight whatever the density does to the slots around it. */
200
+ const eventBody = useMemo(
201
+ () => (info: EventDisplayInfo) => {
202
+ const event = lookup(info.event.id);
203
+ const isRow = isRowEvent(info);
204
+ return cn(
205
+ calendarEventBodyClasses({
206
+ color: event.color,
207
+ layout: isRow ? "row" : "column",
208
+ density: "compact",
209
+ rsvp: event.rsvp,
210
+ status: event.status,
211
+ selected: info.event.id === selectedEventId,
212
+ stacked: false,
213
+ }),
214
+ "cursor-pointer outline-none transition-colors",
215
+ "focus-visible:ring-2 focus-visible:ring-ring",
216
+ /* Tighter than a chip drawn on its own: a grid cell is smaller than
217
+ anywhere else an event lands, and an all-day pill has to fit a band
218
+ one line high. */
219
+ isRow ? "my-px px-1 py-0" : "px-1 py-0.5",
220
+ );
221
+ },
222
+ [lookup, isRowEvent, selectedEventId],
223
+ );
224
+
225
+ const slot = DENSITY[density];
226
+ const isTight = density === "compact";
227
+
228
+ return (
229
+ <div className={cn("h-full min-h-0 w-full text-fg", className)}>
230
+ <FullCalendar
231
+ ref={calendarRef}
232
+ plugins={PLUGINS}
233
+ initialView={FC_VIEW[view]}
234
+ initialDate={date}
235
+ now={now}
236
+ timeZone={timeZone}
237
+ height="100%"
238
+ headerToolbar={false}
239
+ firstDay={1}
240
+ nowIndicator
241
+ selectable
242
+ selectMirror
243
+ editable={false}
244
+ /* Events take focus and answer Enter, the way the toolbar's controls
245
+ do — the grid is not reachable by pointer only. */
246
+ eventInteractive
247
+ expandRows
248
+ allDayText="All day"
249
+ dayMaxEvents={isTight ? 2 : 3}
250
+ eventMaxStack={3}
251
+ moreLinkClick="popover"
252
+ moreLinkText={(num) => `+${num}`}
253
+ slotMinTime="07:00:00"
254
+ slotMaxTime="23:00:00"
255
+ scrollTime="08:30:00"
256
+ scrollTimeReset={false}
257
+ slotDuration={{ minutes: slot.slotMinutes }}
258
+ slotMinHeight={slot.slotMinHeight}
259
+ eventShortHeight={slot.eventShortHeight}
260
+ displayEventEnd={false}
261
+ eventTimeFormat={{ hour: "2-digit", minute: "2-digit", hour12: false }}
262
+ slotHeaderFormat={{ hour: "2-digit", minute: "2-digit", hour12: false }}
263
+ dayHeaderFormat={DAY_HEADER_FORMAT[view]}
264
+ events={eventInputs}
265
+ eventClick={(info) => onSelectEvent(info.event.id)}
266
+ /* One gesture, two readings, and each owns a shape the other cannot
267
+ reach. A point is `dateClick`'s: a finger has to hold a full second
268
+ before the library will call it a selection, and a selection cancels
269
+ the scroll the finger might have meant, so a tap has nowhere else to
270
+ land. A dragged range is `select`'s.
271
+
272
+ Acting on both would report a point twice, and the second report
273
+ arrives late — the library finishes a click a task after the pointer
274
+ is up — by which time the pane the first report opened has rebuilt
275
+ the grid and left the second reading a detached cell. */
276
+ dateClick={(info) => onPickSlot(pointPick(info.dateStr, info.allDay))}
277
+ select={(info) => {
278
+ if (
279
+ !isDraggedSelection(
280
+ info.startStr,
281
+ info.endStr,
282
+ info.allDay,
283
+ slot.slotMinutes,
284
+ )
285
+ ) {
286
+ /* The point reading is answering this one. Left alone the
287
+ selection would stay lit under the draft, a slot wide where
288
+ the draft is an hour, until a click somewhere else. */
289
+ info.view.calendar.unselect();
290
+ return;
291
+ }
292
+ onPickSlot(rangePick(info.startStr, info.endStr, info.allDay));
293
+ }}
294
+ datesSet={(info) => onRangeChange(info.view.title)}
295
+ /* ---- chrome, all of it ours ---- */
296
+ viewClass="bg-surface"
297
+ tableClass="border-line"
298
+ tableHeaderClass="bg-surface"
299
+ fillerClass="border-line"
300
+ dayHeaderRowClass="h-pane-header"
301
+ dayHeaderClass={(info) =>
302
+ cn(
303
+ "border-b border-line bg-surface",
304
+ info.isToday ? "text-accent" : "text-fg-subtle",
305
+ )
306
+ }
307
+ dayHeaderInnerClass="px-1 py-1 text-2xs font-semibold uppercase tracking-wider"
308
+ dayHeaderDividerClass="border-b border-line"
309
+ dayCellClass={(info) =>
310
+ cn("border-line", info.isOther && "bg-surface-sunken")
311
+ }
312
+ dayLaneClass={(info) => cn(info.isToday && "bg-accent-soft/40")}
313
+ dayCellTopInnerClass={(info) =>
314
+ cn(
315
+ "px-1 py-0.5 text-2xs tabular-nums",
316
+ info.isToday ? "font-semibold text-accent" : "text-fg-muted",
317
+ )
318
+ }
319
+ dayRowClass="border-line"
320
+ slotLaneClass={(info) =>
321
+ cn("border-line", info.isMinor && "border-dashed")
322
+ }
323
+ slotHeaderClass="border-line"
324
+ slotHeaderInnerClass="pr-1.5 text-2xs tabular-nums text-fg-subtle"
325
+ slotHeaderDividerClass="border-r border-line"
326
+ allDayHeaderClass="border-line"
327
+ allDayHeaderInnerClass="px-1 text-2xs uppercase tracking-wider text-fg-subtle"
328
+ allDayDividerClass="border-b border-line-strong"
329
+ nonBusinessHoursClass="bg-surface-sunken"
330
+ highlightClass="bg-accent-soft"
331
+ nowIndicatorLineClass="border-t border-danger"
332
+ nowIndicatorDotClass="bg-danger"
333
+ moreLinkClass="rounded-sm bg-surface-sunken text-2xs font-semibold text-fg-muted hover:bg-line hover:text-fg"
334
+ moreLinkInnerClass="px-1 py-0.5"
335
+ popoverClass="rounded-lg border border-line bg-surface-raised shadow-xl shadow-black/20"
336
+ popoverCloseClass="text-fg-subtle hover:text-fg"
337
+ listDaysClass="bg-surface"
338
+ listDayClass="border-line"
339
+ listDayHeaderClass="border-y border-line bg-surface-sunken"
340
+ listDayHeaderInnerClass="flex h-section-row items-center px-row-inset text-2xs font-semibold uppercase tracking-wider text-fg-subtle"
341
+ listDayBodyClass="border-line"
342
+ noEventsClass="p-10 text-center"
343
+ noEventsInnerClass="text-sm text-fg-muted"
344
+ noEventsContent="Nothing scheduled"
345
+ singleMonthClass="p-2"
346
+ singleMonthHeaderClass="pb-1"
347
+ singleMonthHeaderInnerClass="text-xs font-semibold text-fg"
348
+ eventClass={eventBody}
349
+ listItemEventClass={eventBody}
350
+ eventContent={(info) => {
351
+ const event = lookup(info.event.id);
352
+ /* A column an hour wide has room for a time or a title, never
353
+ both — the title is the one worth keeping. */
354
+ const showTime = info.timeText !== "" && !isTight && !info.isNarrow;
355
+ return (
356
+ <CalendarEventChipContent
357
+ title={info.event.title}
358
+ timeText={showTime ? info.timeText : ""}
359
+ layout={isRowEvent(info) ? "row" : "column"}
360
+ rsvp={event.rsvp}
361
+ hasThread={event.hasThread}
362
+ isRecurring={event.isRecurring}
363
+ zoneCertainty={event.zoneCertainty}
364
+ />
365
+ );
366
+ }}
367
+ />
368
+ </div>
369
+ );
370
+ }
@@ -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.
package/src/index.ts CHANGED
@@ -11,6 +11,29 @@ export {
11
11
  type EnvelopeAddress,
12
12
  } from "./components/address-display.js";
13
13
  export { AddressTag, type AddressTagProps } from "./components/address-tag.js";
14
+ export {
15
+ AgendaComposer,
16
+ type AgendaComposerProps,
17
+ AgendaPhraseField,
18
+ type AgendaPhraseFieldProps,
19
+ PhraseReading,
20
+ } from "./components/agenda-composer.js";
21
+ export {
22
+ type AgendaDensity,
23
+ AgendaFlow,
24
+ type AgendaFlowProps,
25
+ type AgendaScrollTarget,
26
+ } from "./components/agenda-flow.js";
27
+ export {
28
+ AgendaDensityControl,
29
+ type AgendaDensityControlProps,
30
+ FreeTimeList,
31
+ type FreeTimeListProps,
32
+ NextUpCard,
33
+ type NextUpCardProps,
34
+ PositionMap,
35
+ type PositionMapProps,
36
+ } from "./components/agenda-panels.js";
14
37
  export {
15
38
  AppPasswordHint,
16
39
  type AppPasswordHintProps,
@@ -124,6 +147,14 @@ export {
124
147
  CalendarEventChip,
125
148
  type CalendarEventChipProps,
126
149
  } from "./components/calendar-event-chip.js";
150
+ export {
151
+ CalendarEventChipContent,
152
+ type CalendarEventChipContentProps,
153
+ } from "./components/calendar-event-chip-content.js";
154
+ export {
155
+ CalendarGrid,
156
+ type CalendarGridProps,
157
+ } from "./components/calendar-grid.js";
127
158
  export {
128
159
  CalendarList,
129
160
  type CalendarListProps,
@@ -137,14 +168,20 @@ export {
137
168
  segmentClassName,
138
169
  } from "./components/calendar-toolbar.js";
139
170
  export {
171
+ type AgendaParse,
140
172
  type CalendarAttendee,
141
173
  type CalendarColorId,
174
+ type CalendarDay,
142
175
  type CalendarDescriptor,
143
176
  type CalendarEventData,
177
+ type CalendarSlotPick,
144
178
  type CalendarViewId,
179
+ type ChoicePicks,
145
180
  calendarColorIds,
146
181
  type EventDraft,
147
182
  type EventSuggestion,
183
+ type PhraseChoice,
184
+ type PhraseChoiceOption,
148
185
  type RecurrenceScope,
149
186
  type RsvpState,
150
187
  type ZoneCertainty,
@@ -797,6 +834,41 @@ export {
797
834
  sanitizeAdoptedHtml,
798
835
  sanitizeQuotedHtml,
799
836
  } from "./lib/adopted-html.js";
837
+ export {
838
+ type AgendaRow,
839
+ addDays,
840
+ type BusySpan,
841
+ buildAgendaRows,
842
+ buildCalendarDay,
843
+ busyMinutesOf,
844
+ busySpansOn,
845
+ type ClashOptions,
846
+ clashesWith,
847
+ conflictsOf,
848
+ DAY_END_MINUTE,
849
+ DAY_START_MINUTE,
850
+ datesBetween,
851
+ FREE_MINUTES,
852
+ type FreeStretch,
853
+ formatMinute,
854
+ formatRunLabel,
855
+ formatShortDay,
856
+ formatSpan,
857
+ freeAhead,
858
+ freeStretchesOn,
859
+ groupOverlapping,
860
+ isClearDay,
861
+ isEmptyDay,
862
+ minuteOfDay,
863
+ monthLabel,
864
+ type NextUp,
865
+ readNextUp,
866
+ shortMonthLabel,
867
+ type WallSpan,
868
+ wallSpanOn,
869
+ weekdayLongLabel,
870
+ weekdayShortLabel,
871
+ } from "./lib/agenda-time.js";
800
872
  export {
801
873
  DEFAULT_ATTACHMENT_FILENAME,
802
874
  formatByteSize,
@@ -817,6 +889,16 @@ export {
817
889
  type CalendarColorClasses,
818
890
  calendarColorClasses,
819
891
  } from "./lib/calendar-color.js";
892
+ export {
893
+ type CalendarEventShell,
894
+ calendarEventBodyClasses,
895
+ } from "./lib/calendar-event-shell.js";
896
+ export {
897
+ DRAFT_MINUTES,
898
+ isDraggedSelection,
899
+ pointPick,
900
+ rangePick,
901
+ } from "./lib/calendar-slot-pick.js";
820
902
  export {
821
903
  buildCidResolver,
822
904
  type CidResolvableBodyPart,