@spatika/react 1.2.0 → 1.3.1

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.
@@ -60,6 +60,37 @@ import {
60
60
  } from "./scheduler-ui";
61
61
  import { DropdownMenuItem } from "../primitives/DropdownMenu";
62
62
 
63
+ export type EventCalendarEventVariant =
64
+ | "month-bar"
65
+ | "month-timed"
66
+ | "all-day"
67
+ | "time-grid"
68
+ | "agenda";
69
+
70
+ export type EventCalendarEventRenderContext = {
71
+ view: CalendarView;
72
+ variant: EventCalendarEventVariant;
73
+ locale?: string;
74
+ hour12: boolean;
75
+ source?: SchedulerEvent;
76
+ defaultChildren: ReactNode;
77
+ };
78
+
79
+ export type EventCalendarToolbarContext = {
80
+ view: CalendarView;
81
+ date: Date;
82
+ title: string;
83
+ views: CalendarView[];
84
+ locale?: string;
85
+ preferences: Required<SchedulerPreferences>;
86
+ onViewChange: (view: CalendarView) => void;
87
+ onPrev: () => void;
88
+ onNext: () => void;
89
+ onToday: () => void;
90
+ onDateChange: (date: Date) => void;
91
+ onPreferencesChange: (next: Required<SchedulerPreferences>) => void;
92
+ };
93
+
63
94
  export type EventCalendarProps = {
64
95
  events?: SchedulerEvent[];
65
96
  resources?: SchedulerResource[];
@@ -85,6 +116,22 @@ export type EventCalendarProps = {
85
116
  onEventCreate?: (event: SchedulerEvent) => void;
86
117
  onEventDelete?: (event: SchedulerEvent) => void;
87
118
  onSlotClick?: (slot: { start: Date; end: Date; allDay: boolean }) => void;
119
+ /**
120
+ * Built-in create/edit dialog. Defaults to true.
121
+ * Set false when the host owns create/edit (custom schedule modal, etc.).
122
+ */
123
+ showEventEditor?: boolean;
124
+ /** Preferences gear in the default toolbar. Ignored when `renderToolbar` is set. */
125
+ showPreferences?: boolean;
126
+ /** Extra controls after the view menu. Ignored when `renderToolbar` is set. */
127
+ toolbarTrailing?: ReactNode;
128
+ /** Replace event chip contents. The kit still wraps with the click/drag trigger. */
129
+ renderEvent?: (
130
+ event: NormalizedEvent,
131
+ context: EventCalendarEventRenderContext,
132
+ ) => ReactNode;
133
+ /** Replace the toolbar. The default kit toolbar is used when omitted. */
134
+ renderToolbar?: (context: EventCalendarToolbarContext) => ReactNode;
88
135
  className?: string;
89
136
  };
90
137
 
@@ -167,6 +214,11 @@ export function EventCalendar({
167
214
  onEventCreate,
168
215
  onEventDelete,
169
216
  onSlotClick,
217
+ showEventEditor = true,
218
+ showPreferences = true,
219
+ toolbarTrailing,
220
+ renderEvent,
221
+ renderToolbar,
170
222
  className,
171
223
  }: EventCalendarProps) {
172
224
  const [view, setView] = useControllableState<CalendarView>({
@@ -238,6 +290,7 @@ export function EventCalendar({
238
290
  function openEvent(event: NormalizedEvent) {
239
291
  const source = sourceEvent(events, event.id);
240
292
  if (source) onEventClick?.(source);
293
+ if (!showEventEditor) return;
241
294
  setDraft(source ? draftFromSource(source) : {
242
295
  id: event.id,
243
296
  title: event.title,
@@ -253,7 +306,7 @@ export function EventCalendar({
253
306
 
254
307
  function openSlot(slot: { start: Date; end: Date; allDay: boolean }) {
255
308
  onSlotClick?.(slot);
256
- if (readOnly) return;
309
+ if (readOnly || !showEventEditor) return;
257
310
  setDraft({ start: slot.start, end: slot.end, allDay: slot.allDay });
258
311
  }
259
312
 
@@ -322,13 +375,28 @@ export function EventCalendar({
322
375
  function renderEventTrigger(
323
376
  event: NormalizedEvent,
324
377
  className: string,
325
- children: ReactNode,
326
- options?: { resize?: "ns" },
378
+ defaultChildren: ReactNode,
379
+ options?: { resize?: "ns"; variant: EventCalendarEventVariant },
327
380
  ) {
381
+ const source = sourceEvent(events, event.id);
382
+ const variant = options?.variant ?? "month-timed";
383
+ const children = renderEvent
384
+ ? renderEvent(event, {
385
+ view: resolvedView,
386
+ variant,
387
+ locale,
388
+ hour12,
389
+ source,
390
+ defaultChildren,
391
+ })
392
+ : defaultChildren;
393
+ if (renderEvent && children == null) return null;
394
+
328
395
  const resizable =
329
396
  options?.resize === "ns" &&
330
397
  eventCanResize(event, { readOnly, enabled: canResize }) &&
331
398
  Boolean(onEventChange);
399
+ const painted = variant === "month-bar" || variant === "all-day" || variant === "time-grid";
332
400
 
333
401
  const button = (
334
402
  <button
@@ -336,9 +404,11 @@ export function EventCalendar({
336
404
  draggable={false}
337
405
  className={className}
338
406
  style={
339
- isMonthBar(event) || resolvedView !== "month"
340
- ? eventColorStyle(event.color, resolvedView === "month" ? "bar" : "block")
341
- : undefined
407
+ variant === "agenda"
408
+ ? eventColorStyle(event.color, "wash")
409
+ : painted
410
+ ? eventColorStyle(event.color, resolvedView === "month" ? "bar" : "block")
411
+ : undefined
342
412
  }
343
413
  onClick={() => {
344
414
  if (dragRef.current?.active) return;
@@ -385,24 +455,46 @@ export function EventCalendar({
385
455
  aria-label="Event calendar"
386
456
  onKeyDown={handleKeyDown}
387
457
  >
388
- <SchedulerToolbar
389
- title={title}
390
- onPrev={() => go(-1)}
391
- onNext={() => go(1)}
392
- onToday={() => setDate(new Date())}
393
- menuLabel={CALENDAR_VIEW_LABEL[resolvedView]}
394
- menu={views.map((item) => (
395
- <DropdownMenuItem key={item} onClick={() => setView(item)}>
396
- {CALENDAR_VIEW_LABEL[item]}
397
- </DropdownMenuItem>
398
- ))}
399
- trailing={
400
- <SchedulerPreferencesMenu
401
- value={resolvedPrefs}
402
- onChange={(next) => setPrefs(next)}
403
- />
404
- }
405
- />
458
+ {renderToolbar ? (
459
+ renderToolbar({
460
+ view: resolvedView,
461
+ date: resolvedDate,
462
+ title,
463
+ views,
464
+ locale,
465
+ preferences: resolvedPrefs,
466
+ onViewChange: (next) => setView(next),
467
+ onPrev: () => go(-1),
468
+ onNext: () => go(1),
469
+ onToday: () => setDate(new Date()),
470
+ onDateChange: (next) => setDate(next),
471
+ onPreferencesChange: (next) => setPrefs(next),
472
+ })
473
+ ) : (
474
+ <SchedulerToolbar
475
+ title={title}
476
+ onPrev={() => go(-1)}
477
+ onNext={() => go(1)}
478
+ onToday={() => setDate(new Date())}
479
+ menuLabel={CALENDAR_VIEW_LABEL[resolvedView]}
480
+ menu={views.map((item) => (
481
+ <DropdownMenuItem key={item} onClick={() => setView(item)}>
482
+ {CALENDAR_VIEW_LABEL[item]}
483
+ </DropdownMenuItem>
484
+ ))}
485
+ trailing={
486
+ <>
487
+ {toolbarTrailing}
488
+ {showPreferences ? (
489
+ <SchedulerPreferencesMenu
490
+ value={resolvedPrefs}
491
+ onChange={(next) => setPrefs(next)}
492
+ />
493
+ ) : null}
494
+ </>
495
+ }
496
+ />
497
+ )}
406
498
 
407
499
  {resolvedView === "month" ? (
408
500
  <MonthGrid
@@ -425,7 +517,7 @@ export function EventCalendar({
425
517
  locale={locale}
426
518
  hour12={hour12}
427
519
  onSlotClick={openSlot}
428
- onEventClick={openEvent}
520
+ renderEventTrigger={renderEventTrigger}
429
521
  />
430
522
  ) : (
431
523
  <TimeGrid
@@ -446,31 +538,33 @@ export function EventCalendar({
446
538
 
447
539
  <ResourceLegend resources={resources} hiddenIds={hiddenIds} onToggle={toggleResource} />
448
540
 
449
- <EventEditor
450
- open={draft != null}
451
- onOpenChange={(open) => {
452
- if (!open) setDraft(null);
453
- }}
454
- draft={draft}
455
- resources={resources}
456
- readOnly={editorLocked}
457
- onSave={(event) => {
458
- if (event.id && sourceEvent(events, event.id)) {
459
- const source = sourceEvent(events, event.id)!;
460
- onEventChange?.(source, event);
461
- } else {
462
- onEventCreate?.(event);
541
+ {showEventEditor ? (
542
+ <EventEditor
543
+ open={draft != null}
544
+ onOpenChange={(open) => {
545
+ if (!open) setDraft(null);
546
+ }}
547
+ draft={draft}
548
+ resources={resources}
549
+ readOnly={editorLocked}
550
+ onSave={(event) => {
551
+ if (event.id && sourceEvent(events, event.id)) {
552
+ const source = sourceEvent(events, event.id)!;
553
+ onEventChange?.(source, event);
554
+ } else {
555
+ onEventCreate?.(event);
556
+ }
557
+ }}
558
+ onDelete={
559
+ onEventDelete
560
+ ? (id) => {
561
+ const source = sourceEvent(events, id);
562
+ if (source) onEventDelete(source);
563
+ }
564
+ : undefined
463
565
  }
464
- }}
465
- onDelete={
466
- onEventDelete
467
- ? (id) => {
468
- const source = sourceEvent(events, id);
469
- if (source) onEventDelete(source);
470
- }
471
- : undefined
472
- }
473
- />
566
+ />
567
+ ) : null}
474
568
  </SchedulerShell>
475
569
  );
476
570
  }
@@ -500,7 +594,7 @@ function MonthGrid({
500
594
  event: NormalizedEvent,
501
595
  className: string,
502
596
  children: ReactNode,
503
- options?: { resize?: "ns" },
597
+ options?: { resize?: "ns"; variant: EventCalendarEventVariant },
504
598
  ) => ReactNode;
505
599
  }) {
506
600
  const weeks = chunkWeeks(getMonthGrid(date, weekStartsOn));
@@ -585,6 +679,7 @@ function MonthGrid({
585
679
  slot.event,
586
680
  "flex h-5 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold leading-none",
587
681
  slot.event.title,
682
+ { variant: "month-bar" },
588
683
  )}
589
684
  </div>
590
685
  ),
@@ -628,6 +723,7 @@ function MonthGrid({
628
723
  </span>
629
724
  <span className="min-w-0 truncate font-medium">{event.title}</span>
630
725
  </>,
726
+ { variant: "month-timed" },
631
727
  )}
632
728
  </div>
633
729
  ))}
@@ -698,7 +794,7 @@ function TimeGrid({
698
794
  event: NormalizedEvent,
699
795
  className: string,
700
796
  children: ReactNode,
701
- options?: { resize?: "ns" },
797
+ options?: { resize?: "ns"; variant: EventCalendarEventVariant },
702
798
  ) => ReactNode;
703
799
  }) {
704
800
  const days =
@@ -756,6 +852,7 @@ function TimeGrid({
756
852
  event,
757
853
  "flex h-5 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold",
758
854
  event.title,
855
+ { variant: "all-day" },
759
856
  )}
760
857
  </div>
761
858
  ))}
@@ -824,7 +921,7 @@ function TimeGrid({
824
921
  {formatTime(item.event.start, locale, hour12)}
825
922
  </span>
826
923
  </>,
827
- { resize: "ns" },
924
+ { resize: "ns", variant: "time-grid" },
828
925
  )}
829
926
  </div>
830
927
  ))}
@@ -852,7 +949,7 @@ function AgendaList({
852
949
  locale,
853
950
  hour12,
854
951
  onSlotClick,
855
- onEventClick,
952
+ renderEventTrigger,
856
953
  }: {
857
954
  date: Date;
858
955
  events: NormalizedEvent[];
@@ -860,7 +957,12 @@ function AgendaList({
860
957
  locale?: string;
861
958
  hour12: boolean;
862
959
  onSlotClick?: EventCalendarProps["onSlotClick"];
863
- onEventClick: (event: NormalizedEvent) => void;
960
+ renderEventTrigger: (
961
+ event: NormalizedEvent,
962
+ className: string,
963
+ children: ReactNode,
964
+ options?: { resize?: "ns"; variant: EventCalendarEventVariant },
965
+ ) => ReactNode;
864
966
  }) {
865
967
  const days = filterWeekendDays(agendaDays(date, AGENDA_LENGTH), showWeekends);
866
968
 
@@ -899,24 +1001,24 @@ function AgendaList({
899
1001
  <p className="pt-2 text-xs text-muted-foreground">No events</p>
900
1002
  ) : (
901
1003
  items.map((event) => (
902
- <button
903
- key={occurrenceKey(event)}
904
- type="button"
905
- data-scheduler-day={isoDay(day)}
906
- className="flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40"
907
- style={eventColorStyle(event.color, "wash")}
908
- onClick={() => onEventClick(event)}
909
- >
910
- <span className="mt-1.5 size-2 shrink-0 rounded-full" style={eventColorStyle(event.color, "dot")} />
911
- <span className="min-w-0">
912
- <span className="block truncate text-sm font-semibold text-foreground">{event.title}</span>
913
- <span className="text-[11px] text-muted-foreground">
914
- {event.allDay || isMonthBar(event)
915
- ? "All day"
916
- : `${formatTime(event.start, locale, hour12)} – ${formatTime(event.end, locale, hour12)}`}
917
- </span>
918
- </span>
919
- </button>
1004
+ <div key={occurrenceKey(event)} data-scheduler-day={isoDay(day)}>
1005
+ {renderEventTrigger(
1006
+ event,
1007
+ "flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40",
1008
+ <>
1009
+ <span className="mt-1.5 size-2 shrink-0 rounded-full" style={eventColorStyle(event.color, "dot")} />
1010
+ <span className="min-w-0">
1011
+ <span className="block truncate text-sm font-semibold text-foreground">{event.title}</span>
1012
+ <span className="text-[11px] text-muted-foreground">
1013
+ {event.allDay || isMonthBar(event)
1014
+ ? "All day"
1015
+ : `${formatTime(event.start, locale, hour12)} – ${formatTime(event.end, locale, hour12)}`}
1016
+ </span>
1017
+ </span>
1018
+ </>,
1019
+ { variant: "agenda" },
1020
+ )}
1021
+ </div>
920
1022
  ))
921
1023
  )}
922
1024
  </div>
@@ -48,6 +48,11 @@ export type EventTimelineProps = {
48
48
  onEventChange?: (event: SchedulerEvent, next: Partial<SchedulerEvent>) => void;
49
49
  onEventCreate?: (event: SchedulerEvent) => void;
50
50
  onEventDelete?: (event: SchedulerEvent) => void;
51
+ /**
52
+ * Built-in create/edit dialog. Defaults to true.
53
+ * Set false when the host owns create/edit.
54
+ */
55
+ showEventEditor?: boolean;
51
56
  className?: string;
52
57
  };
53
58
 
@@ -103,6 +108,7 @@ export function EventTimeline({
103
108
  onEventChange,
104
109
  onEventCreate,
105
110
  onEventDelete,
111
+ showEventEditor = true,
106
112
  className,
107
113
  }: EventTimelineProps) {
108
114
  const [scale, setScale] = useControllableState<TimelineScale>({
@@ -153,6 +159,7 @@ export function EventTimeline({
153
159
  function openEvent(event: NormalizedEvent) {
154
160
  const source = sourceEvent(events, event.id);
155
161
  if (source) onEventClick?.(source);
162
+ if (!showEventEditor) return;
156
163
  setDraft(source ? draftFromSource(source) : {
157
164
  id: event.id,
158
165
  title: event.title,
@@ -328,30 +335,32 @@ export function EventTimeline({
328
335
  onToggle={toggleResource}
329
336
  />
330
337
 
331
- <EventEditor
332
- open={draft != null}
333
- onOpenChange={(open) => {
334
- if (!open) setDraft(null);
335
- }}
336
- draft={draft}
337
- resources={resources}
338
- readOnly={editorLocked}
339
- onSave={(event) => {
340
- if (event.id && sourceEvent(events, event.id)) {
341
- onEventChange?.(sourceEvent(events, event.id)!, event);
342
- } else {
343
- onEventCreate?.(event);
338
+ {showEventEditor ? (
339
+ <EventEditor
340
+ open={draft != null}
341
+ onOpenChange={(open) => {
342
+ if (!open) setDraft(null);
343
+ }}
344
+ draft={draft}
345
+ resources={resources}
346
+ readOnly={editorLocked}
347
+ onSave={(event) => {
348
+ if (event.id && sourceEvent(events, event.id)) {
349
+ onEventChange?.(sourceEvent(events, event.id)!, event);
350
+ } else {
351
+ onEventCreate?.(event);
352
+ }
353
+ }}
354
+ onDelete={
355
+ onEventDelete
356
+ ? (id) => {
357
+ const source = sourceEvent(events, id);
358
+ if (source) onEventDelete(source);
359
+ }
360
+ : undefined
344
361
  }
345
- }}
346
- onDelete={
347
- onEventDelete
348
- ? (id) => {
349
- const source = sourceEvent(events, id);
350
- if (source) onEventDelete(source);
351
- }
352
- : undefined
353
- }
354
- />
362
+ />
363
+ ) : null}
355
364
  </SchedulerShell>
356
365
  );
357
366
  }
@@ -36,6 +36,16 @@ export const TIMELINE_SCALE_LABEL: Record<TimelineScale, string> = {
36
36
  years: "Years",
37
37
  };
38
38
 
39
+ export type SchedulerToolbarProps = {
40
+ title: ReactNode;
41
+ onPrev: () => void;
42
+ onNext: () => void;
43
+ onToday: () => void;
44
+ menuLabel: string;
45
+ menu: ReactNode;
46
+ trailing?: ReactNode;
47
+ };
48
+
39
49
  export function SchedulerToolbar({
40
50
  title,
41
51
  onPrev,
@@ -44,15 +54,7 @@ export function SchedulerToolbar({
44
54
  menuLabel,
45
55
  menu,
46
56
  trailing,
47
- }: {
48
- title: string;
49
- onPrev: () => void;
50
- onNext: () => void;
51
- onToday: () => void;
52
- menuLabel: string;
53
- menu: ReactNode;
54
- trailing?: ReactNode;
55
- }) {
57
+ }: SchedulerToolbarProps) {
56
58
  return (
57
59
  <div
58
60
  data-slot="scheduler-toolbar"
@@ -152,6 +152,75 @@ describe("EventCalendar", () => {
152
152
  await user.click(screen.getByRole("button", { name: "Save" }));
153
153
  expect(onEventCreate).toHaveBeenCalledWith(expect.objectContaining({ title: "Focus time" }));
154
154
  });
155
+
156
+ it("skips the built-in editor when showEventEditor is false", async () => {
157
+ const user = userEvent.setup();
158
+ const onEventClick = vi.fn();
159
+ const onSlotClick = vi.fn();
160
+ render(
161
+ <EventCalendar
162
+ defaultDate={new Date(2026, 7, 15)}
163
+ events={events}
164
+ resources={resources}
165
+ locale="en-US"
166
+ showEventEditor={false}
167
+ onEventClick={onEventClick}
168
+ onSlotClick={onSlotClick}
169
+ />,
170
+ );
171
+ await user.click(screen.getByText("Morning Run"));
172
+ expect(onEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: "run" }));
173
+ expect(screen.queryByRole("heading", { name: /event/i })).not.toBeInTheDocument();
174
+
175
+ await user.click(screen.getByRole("button", { name: "15" }));
176
+ expect(onSlotClick).toHaveBeenCalled();
177
+ expect(screen.queryByRole("heading", { name: "New event" })).not.toBeInTheDocument();
178
+ });
179
+
180
+ it("renders custom event chips and toolbar", async () => {
181
+ const user = userEvent.setup();
182
+ const onDateChange = vi.fn();
183
+ render(
184
+ <EventCalendar
185
+ defaultDate={new Date(2026, 7, 15)}
186
+ events={events}
187
+ resources={resources}
188
+ locale="en-US"
189
+ showEventEditor={false}
190
+ showPreferences={false}
191
+ toolbarTrailing={<button type="button">Jump year</button>}
192
+ renderEvent={(event) => <span>★ {event.title}</span>}
193
+ renderToolbar={({ title, onToday }) => (
194
+ <div>
195
+ <p>{title}</p>
196
+ <button type="button" onClick={onToday}>
197
+ Jump today
198
+ </button>
199
+ </div>
200
+ )}
201
+ onDateChange={onDateChange}
202
+ />,
203
+ );
204
+ expect(screen.getByText("★ Morning Run")).toBeInTheDocument();
205
+ expect(screen.queryByRole("button", { name: "Preferences" })).not.toBeInTheDocument();
206
+ expect(screen.queryByRole("button", { name: "Jump year" })).not.toBeInTheDocument();
207
+ await user.click(screen.getByRole("button", { name: "Jump today" }));
208
+ expect(onDateChange).toHaveBeenCalled();
209
+ });
210
+
211
+ it("keeps toolbarTrailing on the default toolbar", () => {
212
+ render(
213
+ <EventCalendar
214
+ defaultDate={new Date(2026, 7, 15)}
215
+ events={events}
216
+ locale="en-US"
217
+ showPreferences={false}
218
+ toolbarTrailing={<button type="button">Jump year</button>}
219
+ />,
220
+ );
221
+ expect(screen.getByRole("button", { name: "Jump year" })).toBeInTheDocument();
222
+ expect(screen.getByRole("heading", { name: /August 2026/i })).toBeInTheDocument();
223
+ });
155
224
  });
156
225
 
157
226
  describe("EventTimeline", () => {
@@ -191,4 +260,21 @@ describe("EventTimeline", () => {
191
260
  expect(screen.getByText("API V3 Development")).toBeInTheDocument();
192
261
  expect(screen.getByText("Mobile App UI/UX")).toBeInTheDocument();
193
262
  });
263
+
264
+ it("skips the built-in editor when showEventEditor is false", async () => {
265
+ const user = userEvent.setup();
266
+ const onEventClick = vi.fn();
267
+ render(
268
+ <EventTimeline
269
+ defaultDate={new Date(2026, 7, 1)}
270
+ events={timelineEvents}
271
+ resources={timelineResources}
272
+ showEventEditor={false}
273
+ onEventClick={onEventClick}
274
+ />,
275
+ );
276
+ await user.click(screen.getByText("API V3 Development"));
277
+ expect(onEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
278
+ expect(screen.queryByRole("heading", { name: /event/i })).not.toBeInTheDocument();
279
+ });
194
280
  });
package/src/index.test.ts CHANGED
@@ -5,7 +5,9 @@ import {
5
5
  Button,
6
6
  ChartContainer,
7
7
  ChartDataGrid,
8
+ EventCalendar,
8
9
  MapChart,
10
+ SchedulerToolbar,
9
11
  mercator,
10
12
  } from "./index";
11
13
 
@@ -17,6 +19,8 @@ describe("@spatika/react public exports", () => {
17
19
  expect(ChartDataGrid).toBeTypeOf("function");
18
20
  expect(MapChart).toBeTypeOf("function");
19
21
  expect(BarChart3D).toBeTypeOf("function");
22
+ expect(EventCalendar).toBeTypeOf("function");
23
+ expect(SchedulerToolbar).toBeTypeOf("function");
20
24
  expect(mercator([0, 0])[0]).toBeCloseTo(0.5);
21
25
  });
22
26
  });
package/src/index.ts CHANGED
@@ -486,6 +486,9 @@ export {
486
486
  export {
487
487
  EventCalendar,
488
488
  type EventCalendarProps,
489
+ type EventCalendarEventVariant,
490
+ type EventCalendarEventRenderContext,
491
+ type EventCalendarToolbarContext,
489
492
  } from "./composites/EventCalendar";
490
493
  export {
491
494
  EventTimeline,
@@ -495,12 +498,19 @@ export {
495
498
  EventEditor,
496
499
  type EventEditorDraft,
497
500
  } from "./composites/EventEditor";
501
+ export {
502
+ SchedulerToolbar,
503
+ SchedulerPreferencesMenu,
504
+ CALENDAR_VIEW_LABEL,
505
+ type SchedulerToolbarProps,
506
+ } from "./composites/scheduler-ui";
498
507
  export {
499
508
  DEFAULT_SCHEDULER_PREFERENCES,
500
509
  SCHEDULER_COLOR_TONES,
501
510
  } from "./lib/scheduler";
502
511
  export type {
503
512
  CalendarView,
513
+ NormalizedEvent,
504
514
  SchedulerColorTone,
505
515
  SchedulerEvent,
506
516
  SchedulerPreferences,
@@ -65,6 +65,19 @@ describe("scheduler dates", () => {
65
65
  expect(occupiesDay(birthday, new Date(2026, 7, 4))).toBe(true);
66
66
  expect(occupiesDay(birthday, new Date(2026, 7, 5))).toBe(false);
67
67
  });
68
+
69
+ it("preserves host data on normalized events", () => {
70
+ const [event] = normalizeEvents([
71
+ {
72
+ id: "run",
73
+ title: "Morning Run",
74
+ start: "2026-08-03T07:00:00",
75
+ end: "2026-08-03T07:45:00",
76
+ data: { kind: "CATCHUP" },
77
+ },
78
+ ]);
79
+ expect(event.data).toEqual({ kind: "CATCHUP" });
80
+ });
68
81
  });
69
82
 
70
83
  describe("scheduler layout", () => {