@super-calendar/core 2.0.0

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/dist/index.mjs ADDED
@@ -0,0 +1,747 @@
1
+ import { addDays, addMonths, addWeeks, addYears, differenceInMinutes, eachDayOfInterval, endOfMonth, endOfWeek, format, getHours, getMinutes, isAfter, isBefore, isSameDay, isSameMonth, isToday, max, min, startOfDay, startOfMonth, startOfWeek } from "date-fns";
2
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
3
+ //#region src/tokens.ts
4
+ const lightColors = {
5
+ gridLine: "#E2E4E9",
6
+ weekendBackground: "#F6F7F9",
7
+ outsideHoursBackground: "#F1F2F4",
8
+ todayBackground: "#1F6FEB",
9
+ todayText: "#FFFFFF",
10
+ selectedBackground: "#1F6FEB",
11
+ selectedText: "#FFFFFF",
12
+ rangeBackground: "#DCE7FF",
13
+ hoverBackground: "#E6ECF5",
14
+ nowIndicator: "#E5484D",
15
+ text: "#1A1B1E",
16
+ textMuted: "#6B7280",
17
+ textDisabled: "#B5B9C0",
18
+ eventBackground: "#DCE7FF",
19
+ eventText: "#1A1B1E"
20
+ };
21
+ const darkColors = {
22
+ gridLine: "#2A2E37",
23
+ weekendBackground: "#15171C",
24
+ outsideHoursBackground: "#101216",
25
+ todayBackground: "#1F6FEB",
26
+ todayText: "#FFFFFF",
27
+ selectedBackground: "#1F6FEB",
28
+ selectedText: "#FFFFFF",
29
+ rangeBackground: "#243B53",
30
+ hoverBackground: "#2E3138",
31
+ nowIndicator: "#E5484D",
32
+ text: "#ECEDEE",
33
+ textMuted: "#9BA1A6",
34
+ textDisabled: "#4B4F58",
35
+ eventBackground: "#243B53",
36
+ eventText: "#EAF2FF"
37
+ };
38
+ //#endregion
39
+ //#region src/presentation.ts
40
+ /** The band shape for a day, given the fill-cell option. */
41
+ function rangeBandKind(day, fillCell) {
42
+ if (!day.isInRange || day.isRangeStart && day.isRangeEnd) return "none";
43
+ if (fillCell) return "fill";
44
+ if (day.isRangeStart) return "pill-start";
45
+ if (day.isRangeEnd) return "pill-end";
46
+ return "pill-mid";
47
+ }
48
+ /** Whether a band shape rounds its leading / trailing edge (pill ends). */
49
+ function bandRounding(kind) {
50
+ return {
51
+ start: kind === "pill-start",
52
+ end: kind === "pill-end"
53
+ };
54
+ }
55
+ /**
56
+ * The filled-badge kind for a day. `isSelected` is true for both range endpoints
57
+ * and discrete selected days; today always wins when it coincides.
58
+ */
59
+ function dayBadgeKind(day, isToday) {
60
+ if (isToday) return "today";
61
+ if (day.isSelected) return "selected";
62
+ return "none";
63
+ }
64
+ //#endregion
65
+ //#region src/utils/dates.ts
66
+ /** The seven dates of the week containing `date`, starting on `weekStartsOn`. */
67
+ const getWeekDays = (date, weekStartsOn) => {
68
+ const start = startOfWeek(date, { weekStartsOn });
69
+ return Array.from({ length: 7 }, (_, index) => addDays(start, index));
70
+ };
71
+ /** How many day columns a time-grid mode shows. `custom` uses `numberOfDays`. */
72
+ const viewDayCount = (mode, numberOfDays = 1) => {
73
+ switch (mode) {
74
+ case "week": return 7;
75
+ case "3days": return 3;
76
+ case "custom": return Math.max(1, Math.floor(numberOfDays));
77
+ default: return 1;
78
+ }
79
+ };
80
+ /**
81
+ * Days in the inclusive span from `weekStartsOn` to `weekEndsOn` (1–7),
82
+ * wrapping when the end precedes the start (e.g. Sat→Wed). Mirrors
83
+ * react-native-big-calendar's `weekDaysCount`.
84
+ */
85
+ const weekDaysCount = (weekStartsOn, weekEndsOn) => {
86
+ if (weekEndsOn < weekStartsOn) {
87
+ let count = 1;
88
+ let i = weekStartsOn;
89
+ while (i !== weekEndsOn && count <= 7) {
90
+ i = (i + 1) % 7;
91
+ count++;
92
+ }
93
+ return count;
94
+ }
95
+ if (weekEndsOn > weekStartsOn) return weekEndsOn - weekStartsOn + 1;
96
+ return 1;
97
+ };
98
+ /**
99
+ * The day columns to render for a time-grid page. `week` spans the calendar week
100
+ * (honouring `weekStartsOn`). `custom` with a `weekEndsOn` spans the partial week
101
+ * from `weekStartsOn` to `weekEndsOn` (anchored to `date`'s week, paging by week);
102
+ * otherwise every mode shows `viewDayCount` consecutive days starting at `date`.
103
+ */
104
+ const getViewDays = (mode, date, weekStartsOn, numberOfDays = 1, isRTL = false, weekEndsOn) => {
105
+ let days;
106
+ if (mode === "week") days = getWeekDays(date, weekStartsOn);
107
+ else if (mode === "custom" && weekEndsOn != null) {
108
+ const subject = startOfDay(date);
109
+ const offset = weekStartsOn - subject.getDay();
110
+ days = Array.from({ length: weekDaysCount(weekStartsOn, weekEndsOn) }, (_, index) => addDays(subject, index + offset));
111
+ } else days = Array.from({ length: viewDayCount(mode, numberOfDays) }, (_, index) => addDays(startOfDay(date), index));
112
+ return isRTL ? days.reverse() : days;
113
+ };
114
+ /**
115
+ * The calendar weeks covering `month`, padded to whole weeks starting on
116
+ * `weekStartsOn`. `showSixWeeks` always returns six rows (42 days) for a
117
+ * fixed-height grid; `isRTL` reverses each week's day order.
118
+ */
119
+ const buildMonthWeeks = (month, weekStartsOn, { showSixWeeks = false, isRTL = false } = {}) => {
120
+ const start = startOfWeek(startOfMonth(month), { weekStartsOn });
121
+ const days = eachDayOfInterval({
122
+ start,
123
+ end: showSixWeeks ? addDays(start, 41) : endOfWeek(endOfMonth(month), { weekStartsOn })
124
+ });
125
+ const weeks = [];
126
+ for (let index = 0; index < days.length; index += 7) {
127
+ const week = days.slice(index, index + 7);
128
+ weeks.push(isRTL ? week.reverse() : week);
129
+ }
130
+ return weeks;
131
+ };
132
+ const isWeekend = (date) => {
133
+ const day = date.getDay();
134
+ return day === 0 || day === 6;
135
+ };
136
+ const getIsToday = (date) => isToday(date);
137
+ const isSameCalendarDay = (a, b) => isSameDay(a, b);
138
+ /** Minutes elapsed since midnight (0–1439). */
139
+ const minutesIntoDay = (date) => getHours(date) * 60 + getMinutes(date);
140
+ //#endregion
141
+ //#region src/utils/dateRange.ts
142
+ /** Whether `date` passes the min/max/disabled constraints (compared by calendar day). */
143
+ function isDateSelectable(date, constraints = {}) {
144
+ const day = startOfDay(date);
145
+ if (constraints.minDate && isBefore(day, startOfDay(constraints.minDate))) return false;
146
+ if (constraints.maxDate && isAfter(day, startOfDay(constraints.maxDate))) return false;
147
+ if (constraints.isDateDisabled?.(day)) return false;
148
+ return true;
149
+ }
150
+ /**
151
+ * The range after pressing `pressed`, mirroring the familiar date-picker model:
152
+ * - no range yet, or a complete range exists → start fresh (`{ start: pressed, end: null }`),
153
+ * so a third press resets the selection.
154
+ * - an open range (a start but no end) → close it, auto-swapping when the press
155
+ * precedes the start so `start <= end` always holds.
156
+ *
157
+ * Returns `current` unchanged when `pressed` isn't selectable.
158
+ */
159
+ function nextDateRange(current, pressed, constraints = {}) {
160
+ if (!isDateSelectable(pressed, constraints)) return current;
161
+ const day = startOfDay(pressed);
162
+ if (!current || current.end) return {
163
+ start: day,
164
+ end: null
165
+ };
166
+ if (isBefore(day, current.start)) return {
167
+ start: day,
168
+ end: current.start
169
+ };
170
+ return {
171
+ start: current.start,
172
+ end: day
173
+ };
174
+ }
175
+ /** True when `date` is one of the range's two endpoints. */
176
+ function isRangeEndpoint(date, range) {
177
+ if (!range) return false;
178
+ if (isSameCalendarDay(date, range.start)) return true;
179
+ return range.end ? isSameCalendarDay(date, range.end) : false;
180
+ }
181
+ /** True when `date` falls within a complete range (endpoints included). */
182
+ function isWithinDateRange(date, range) {
183
+ if (!range || !range.end) return false;
184
+ const day = startOfDay(date).getTime();
185
+ const a = startOfDay(range.start).getTime();
186
+ const b = startOfDay(range.end).getTime();
187
+ return day >= Math.min(a, b) && day <= Math.max(a, b);
188
+ }
189
+ /**
190
+ * The canonical per-day selection state, shared by `MonthView` (rendering) and
191
+ * `buildMonthGrid` (the headless grid) so the built-in views and a custom
192
+ * calendar can never disagree on what a day's state is.
193
+ */
194
+ function daySelectionState(date, selection, constraints = {}) {
195
+ const isDisabled = !isDateSelectable(date, constraints);
196
+ const range = selection.selectedRange ?? null;
197
+ return {
198
+ isDisabled,
199
+ isSelected: !isDisabled && ((selection.selectedDates?.some((selected) => isSameCalendarDay(selected, date)) ?? false) || isRangeEndpoint(date, range)),
200
+ isInRange: !isDisabled && isWithinDateRange(date, range),
201
+ isRangeStart: !isDisabled && range != null && isSameCalendarDay(date, range.start),
202
+ isRangeEnd: !isDisabled && range?.end != null && isSameCalendarDay(date, range.end)
203
+ };
204
+ }
205
+ const CalendarSelectionContext = createContext({});
206
+ /**
207
+ * Provides the active selection to the month grid. Day cells read it via
208
+ * {@link useCalendarSelection} so they repaint on selection changes even when
209
+ * the virtualized list has cached (and so won't re-render) their page.
210
+ */
211
+ const CalendarSelectionProvider = CalendarSelectionContext.Provider;
212
+ const useCalendarSelection = () => useContext(CalendarSelectionContext);
213
+ /**
214
+ * Controlled-ish range selection state for the month view. Returns the current
215
+ * `range` plus an `onPressDate` handler to wire to `Calendar`'s `onPressDay`, a
216
+ * `reset`, and the raw `setRange` for full control.
217
+ *
218
+ * ```tsx
219
+ * const { range, onPressDate } = useDateRange({ minDate: new Date() });
220
+ * <Calendar mode="month" selectedRange={range ?? undefined} onPressDay={onPressDate} … />
221
+ * ```
222
+ */
223
+ function useDateRange(options = {}) {
224
+ const { initialRange = null, minDate, maxDate, isDateDisabled } = options;
225
+ const [range, setRange] = useState(initialRange);
226
+ const constraints = useMemo(() => ({
227
+ minDate,
228
+ maxDate,
229
+ isDateDisabled
230
+ }), [
231
+ minDate,
232
+ maxDate,
233
+ isDateDisabled
234
+ ]);
235
+ return {
236
+ range,
237
+ onPressDate: useCallback((date) => setRange((previous) => nextDateRange(previous, date, constraints)), [constraints]),
238
+ selectRange: useCallback((a, b) => {
239
+ if (!isDateSelectable(a, constraints) || !isDateSelectable(b, constraints)) return;
240
+ const [start, end] = a.getTime() <= b.getTime() ? [a, b] : [b, a];
241
+ setRange({
242
+ start: startOfDay(start),
243
+ end: startOfDay(end)
244
+ });
245
+ }, [constraints]),
246
+ reset: useCallback(() => setRange(null), []),
247
+ setRange
248
+ };
249
+ }
250
+ //#endregion
251
+ //#region src/utils/drag.ts
252
+ /**
253
+ * Minutes to shift an event, snapping a vertical pixel drag to the nearest
254
+ * `stepMinutes`. Runs on the UI thread inside the drag gesture. Returns 0 for a
255
+ * degenerate grid (non-positive height/step).
256
+ */
257
+ function snapDeltaMinutes(translationPx, cellHeightPx, stepMinutes) {
258
+ "worklet";
259
+ if (cellHeightPx <= 0 || stepMinutes <= 0) return 0;
260
+ const rawMinutes = translationPx / cellHeightPx * 60;
261
+ return Math.round(rawMinutes / stepMinutes) * stepMinutes;
262
+ }
263
+ /** A copy of `date` shifted by `minutes` (may be negative). */
264
+ function shiftMinutes(date, minutes) {
265
+ const next = new Date(date);
266
+ next.setMinutes(next.getMinutes() + minutes);
267
+ return next;
268
+ }
269
+ /**
270
+ * Resolve a committed drag into the event's new bounds: `start` shifts by
271
+ * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
272
+ * to both; a resize passes 0 for the start). Returns `null` when the change
273
+ * would collapse the event below one `snapMinutes` step, so the gesture leaves
274
+ * the event untouched rather than committing a degenerate duration. Pure, so the
275
+ * commit path is unit-testable without a running gesture.
276
+ */
277
+ function resolveDraggedBounds(start, end, deltaStartMinutes, deltaEndMinutes, snapMinutes) {
278
+ const nextStart = shiftMinutes(start, deltaStartMinutes);
279
+ const nextEnd = shiftMinutes(end, deltaEndMinutes);
280
+ if (nextEnd.getTime() - nextStart.getTime() < snapMinutes * 6e4) return null;
281
+ return {
282
+ start: nextStart,
283
+ end: nextEnd
284
+ };
285
+ }
286
+ /**
287
+ * The start/end of a new event swept out on `day` by dragging from `startPx` to
288
+ * `endPx` (vertical pixels from the grid's top, i.e. the `minHour` line). Both
289
+ * ends snap to `snapMinutes`; the range is ordered (drag up or down) and widened
290
+ * to at least one step so a stationary press still yields a usable event.
291
+ * Returns `null` for a degenerate grid (non-positive height/step). Pure, so the
292
+ * commit path is unit-testable without a running gesture.
293
+ */
294
+ function cellRangeFromDrag(day, startPx, endPx, cellHeightPx, minHour, snapMinutes) {
295
+ if (cellHeightPx <= 0 || snapMinutes <= 0) return null;
296
+ const snapAt = (px) => {
297
+ const rawMinutes = (minHour + px / cellHeightPx) * 60;
298
+ return Math.round(rawMinutes / snapMinutes) * snapMinutes;
299
+ };
300
+ const MAX = 1440;
301
+ let lower = Math.max(0, Math.min(MAX - snapMinutes, snapAt(startPx)));
302
+ let upper = Math.max(0, Math.min(MAX, snapAt(endPx)));
303
+ if (upper < lower) [lower, upper] = [upper, lower];
304
+ if (upper - lower < snapMinutes) upper = Math.min(MAX, lower + snapMinutes);
305
+ const start = new Date(day);
306
+ start.setHours(0, 0, 0, 0);
307
+ start.setMinutes(lower);
308
+ const end = new Date(day);
309
+ end.setHours(0, 0, 0, 0);
310
+ end.setMinutes(upper);
311
+ return {
312
+ start,
313
+ end
314
+ };
315
+ }
316
+ //#endregion
317
+ //#region src/utils/eventDisplay.ts
318
+ /**
319
+ * Minimum event-box height (px) before the built-in renderer shows the time line
320
+ * on a narrow multi-column timed grid. Tied to the default theme's font sizes.
321
+ */
322
+ const MIN_BOX_HEIGHT_FOR_TIME = 56;
323
+ /** Hard-clip an overflowing title by default; opt into a trailing ellipsis. */
324
+ function titleEllipsizeMode(ellipsizeTitle) {
325
+ return ellipsizeTitle ? "tail" : "clip";
326
+ }
327
+ /**
328
+ * Screen-reader label for an event: its title followed by "all day" or its time
329
+ * range (which the grid otherwise only conveys visually). Empty title is dropped.
330
+ */
331
+ function eventAccessibilityLabel(args) {
332
+ const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
333
+ const time = args.isAllDay ? args.allDayLabel ?? "all day" : `${format(args.start, timeFormat)} to ${format(args.end, timeFormat)}`;
334
+ return [args.title, time].filter(Boolean).join(", ");
335
+ }
336
+ /**
337
+ * Month cells and the all-day lane show a single clipped line; timed-grid titles
338
+ * (`undefined`) wrap to fill the box.
339
+ */
340
+ function titleNumberOfLines(mode, isAllDay) {
341
+ return mode === "month" || isAllDay ? 1 : void 0;
342
+ }
343
+ /**
344
+ * The secondary line under the title in the built-in renderer, or `null` when
345
+ * none should show. Timed events get their `start - end` range. An all-day event
346
+ * gets the literal "All day" in the schedule (which has no all-day lane to
347
+ * signal it positionally) and nothing on the day/week grid (the lane already
348
+ * does). Month cells and `showTime={false}` always return `null`.
349
+ */
350
+ function eventTimeLabel(args) {
351
+ if (!args.showTime || args.mode === "month") return null;
352
+ if (args.isAllDay) return args.mode === "schedule" ? args.allDayLabel ?? "All day" : null;
353
+ const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
354
+ return `${format(args.start, timeFormat)} - ${format(args.end, timeFormat)}`;
355
+ }
356
+ /**
357
+ * The default hour-axis label shared by both renderers' time grids, so the gutter
358
+ * reads the same on each: 24-hour "HH:00" (e.g. "08:00"), or a compact 12-hour
359
+ * "h AM/PM" (e.g. "8 AM") when `ampm` is set. Exported so a custom hour renderer
360
+ * can reuse the same formatting.
361
+ */
362
+ function formatHour(hour, opts) {
363
+ if (opts?.ampm) {
364
+ const period = hour < 12 ? "AM" : "PM";
365
+ return `${hour % 12 === 0 ? 12 : hour % 12} ${period}`;
366
+ }
367
+ return `${String(hour).padStart(2, "0")}:00`;
368
+ }
369
+ /**
370
+ * Whether the time line fits in the box. The wide `day` column and contexts with
371
+ * no live box height (e.g. schedule, where `boxHeightPx` is undefined) always
372
+ * show it; narrow multi-column modes only once the box is at least
373
+ * {@link MIN_BOX_HEIGHT_FOR_TIME} tall. Runs on the UI thread inside the event
374
+ * renderer's animated style.
375
+ */
376
+ function isTimeVisibleAtHeight(boxHeightPx, mode) {
377
+ "worklet";
378
+ if (boxHeightPx == null || mode === "day") return true;
379
+ return boxHeightPx >= 56;
380
+ }
381
+ /**
382
+ * Lay out the built-in timed-grid event chip for a box of `boxHeightPx`: how
383
+ * many whole title lines fit, and whether the time line still has room below
384
+ * them. The title is primary, so the title fills the box in whole lines (never a
385
+ * half-cropped line) and the time only shows once a full line is left over. Pass
386
+ * `titleLineHeightPx`/`timeLineHeightPx` matching the rendered line heights so
387
+ * the clamp lands on a line boundary.
388
+ *
389
+ * Worklet-safe, so the native renderer can drive the title's max-height on the UI
390
+ * thread as the grid zooms; the dom renderer calls it with its static box height.
391
+ * A `boxHeightPx` of `undefined` (the schedule has no live box height) returns
392
+ * `titleMaxLines: 0` (no clamp) with the unconditional time visibility.
393
+ */
394
+ function eventChipLayout(args) {
395
+ "worklet";
396
+ const wantTime = args.hasTime && isTimeVisibleAtHeight(args.boxHeightPx, args.mode);
397
+ if (args.boxHeightPx == null || args.titleLineHeightPx <= 0) return {
398
+ titleMaxLines: 0,
399
+ showTime: wantTime
400
+ };
401
+ const inner = args.boxHeightPx - args.paddingYPx * 2;
402
+ const titleMaxLines = Math.max(1, Math.floor((inner - (wantTime ? args.timeLineHeightPx : 0)) / args.titleLineHeightPx));
403
+ return {
404
+ titleMaxLines,
405
+ showTime: wantTime && inner - titleMaxLines * args.titleLineHeightPx >= args.timeLineHeightPx
406
+ };
407
+ }
408
+ /**
409
+ * Derive how many event chips fit in a month cell from the measured space.
410
+ * `chipRowHeightPx` is one chip plus its gap; `moreRowHeightPx` is the overflow
411
+ * label plus its gap. Both counts are clamped to >= 0.
412
+ */
413
+ function monthEventCapacity(availableHeightPx, chipRowHeightPx, moreRowHeightPx) {
414
+ if (chipRowHeightPx <= 0) return {
415
+ full: 0,
416
+ withMore: 0
417
+ };
418
+ return {
419
+ full: Math.max(0, Math.floor(availableHeightPx / chipRowHeightPx)),
420
+ withMore: Math.max(0, Math.floor((availableHeightPx - moreRowHeightPx) / chipRowHeightPx))
421
+ };
422
+ }
423
+ /**
424
+ * Chips to show for a day: all of them when they fit, otherwise `withMore` (at
425
+ * least one) so the rest collapse into a "+N more" label.
426
+ */
427
+ function monthVisibleCount(total, capacity) {
428
+ if (total <= capacity.full) return total;
429
+ return Math.max(1, capacity.withMore);
430
+ }
431
+ //#endregion
432
+ //#region src/utils/layout.ts
433
+ const MINUTES_PER_HOUR = 60;
434
+ const MIN_DURATION_HOURS = .25;
435
+ /**
436
+ * Lay out a single day's events: events that overlap in time are split into
437
+ * side-by-side columns. Multi-day events are clipped to the portion that falls
438
+ * on `day` (e.g. a 23:00→01:00 event renders 23:00–24:00 on the start day and
439
+ * 00:00–01:00 on the next). Pure — safe to call per render, never per frame.
440
+ */
441
+ function layoutDayEvents(events, day) {
442
+ const dayStart = startOfDay(day);
443
+ const nextDayStart = addDays(dayStart, 1);
444
+ const segments = events.filter((event) => !isAllDayEvent(event)).filter((event) => event.start < nextDayStart && event.end > dayStart).map((event) => {
445
+ const segStart = max([event.start, dayStart]);
446
+ const segEnd = min([event.end, nextDayStart]);
447
+ return {
448
+ event,
449
+ start: differenceInMinutes(segStart, dayStart) / MINUTES_PER_HOUR,
450
+ end: differenceInMinutes(segEnd, dayStart) / MINUTES_PER_HOUR,
451
+ continuesBefore: event.start < dayStart,
452
+ continuesAfter: event.end > nextDayStart
453
+ };
454
+ }).sort((a, b) => a.start - b.start);
455
+ const positioned = [];
456
+ let cluster = [];
457
+ let clusterEnd = Number.NEGATIVE_INFINITY;
458
+ const flushCluster = () => {
459
+ const columnEnds = [];
460
+ const columnOf = /* @__PURE__ */ new Map();
461
+ for (const seg of cluster) {
462
+ let column = columnEnds.findIndex((end) => end <= seg.start);
463
+ if (column === -1) {
464
+ column = columnEnds.length;
465
+ columnEnds.push(seg.end);
466
+ } else columnEnds[column] = seg.end;
467
+ columnOf.set(seg, column);
468
+ }
469
+ for (const seg of cluster) positioned.push({
470
+ event: seg.event,
471
+ startHours: seg.start,
472
+ durationHours: Math.max(seg.end - seg.start, MIN_DURATION_HOURS),
473
+ column: columnOf.get(seg) ?? 0,
474
+ columns: columnEnds.length,
475
+ continuesBefore: seg.continuesBefore,
476
+ continuesAfter: seg.continuesAfter
477
+ });
478
+ cluster = [];
479
+ };
480
+ for (const seg of segments) {
481
+ if (cluster.length > 0 && seg.start >= clusterEnd) flushCluster();
482
+ cluster.push(seg);
483
+ clusterEnd = Math.max(clusterEnd, seg.end);
484
+ }
485
+ if (cluster.length > 0) flushCluster();
486
+ return positioned;
487
+ }
488
+ const atMidnight = (date) => date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0;
489
+ /**
490
+ * Whether an event belongs in the all-day lane. An explicit `allDay` flag wins;
491
+ * otherwise it's inferred when the event spans whole days (both `start` and
492
+ * `end` land on midnight, e.g. an iCal-style all-day event). Pure.
493
+ */
494
+ function isAllDayEvent(event) {
495
+ if (typeof event.allDay === "boolean") return event.allDay;
496
+ return event.end > event.start && atMidnight(event.start) && atMidnight(event.end);
497
+ }
498
+ /**
499
+ * The `startOfDay` ISO keys of every calendar day an event touches (inclusive).
500
+ * An event ending exactly at midnight does not count the following day. Used to
501
+ * index events by day for the month grid. Pure.
502
+ */
503
+ function eventDayKeys(event) {
504
+ const first = startOfDay(event.start);
505
+ const last = startOfDay(event.end > event.start ? /* @__PURE__ */ new Date(event.end.getTime() - 1) : event.start);
506
+ const keys = [];
507
+ for (let cursor = first; cursor <= last; cursor = addDays(cursor, 1)) keys.push(cursor.toISOString());
508
+ return keys;
509
+ }
510
+ /**
511
+ * Index events by the `startOfDay` ISO key of every day they touch (via
512
+ * {@link eventDayKeys}), so a month grid can look up a day's events with
513
+ * `startOfDay(date).toISOString()`. Built once and shared across month cells.
514
+ */
515
+ function groupEventsByDay(events) {
516
+ const map = /* @__PURE__ */ new Map();
517
+ for (const event of events) for (const key of eventDayKeys(event)) {
518
+ const list = map.get(key);
519
+ if (list) list.push(event);
520
+ else map.set(key, [event]);
521
+ }
522
+ return map;
523
+ }
524
+ /**
525
+ * Order a day's events for the month and list views: all-day events come first
526
+ * (they head the day regardless of their start time), then timed events by start.
527
+ * Shared by both renderers so the order is identical. Use as an `Array.sort`
528
+ * comparator.
529
+ */
530
+ function compareDayEvents(a, b) {
531
+ const aAllDay = isAllDayEvent(a);
532
+ if (aAllDay !== isAllDayEvent(b)) return aAllDay ? -1 : 1;
533
+ return a.start.getTime() - b.start.getTime();
534
+ }
535
+ /**
536
+ * The closed hour-spans of a day to shade on the time grid, given a
537
+ * `businessHours` callback and the visible `[minHour, maxHour]` window: the spans
538
+ * before open and after close (clamped to the window), the whole window when the
539
+ * day is closed (`null`) or the open hours are inverted/empty, or none when the
540
+ * callback returns `undefined`. Shared by both renderers so shading stays
541
+ * identical. Co-located with `groupEventsByDay`; both feed the grid layout.
542
+ */
543
+ function closedHourBands(day, businessHours, minHour = 0, maxHour = 24) {
544
+ const open = businessHours?.(day);
545
+ if (open === void 0) return [];
546
+ if (open === null) return [{
547
+ start: minHour,
548
+ end: maxHour
549
+ }];
550
+ const start = Math.max(minHour, Math.min(maxHour, open.start));
551
+ const end = Math.max(minHour, Math.min(maxHour, open.end));
552
+ if (start >= end) return [{
553
+ start: minHour,
554
+ end: maxHour
555
+ }];
556
+ const bands = [];
557
+ if (start > minHour) bands.push({
558
+ start: minHour,
559
+ end: start
560
+ });
561
+ if (end < maxHour) bands.push({
562
+ start: end,
563
+ end: maxHour
564
+ });
565
+ return bands;
566
+ }
567
+ //#endregion
568
+ //#region src/utils/monthGrid.ts
569
+ /**
570
+ * Pure month-grid builder: the weeks and weekday headers for `month`, each day
571
+ * annotated with selection/disabled/today state. Use this when you need the
572
+ * data outside React; inside a component prefer {@link useMonthGrid}.
573
+ */
574
+ function buildMonthGrid(month, options = {}) {
575
+ const { weekStartsOn = 0, showSixWeeks = false, isRTL = false, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
576
+ const rows = buildMonthWeeks(month, weekStartsOn, {
577
+ showSixWeeks,
578
+ isRTL
579
+ });
580
+ return {
581
+ weeks: rows.map((days) => ({
582
+ id: days[0].toISOString(),
583
+ days: days.map((date) => ({
584
+ date,
585
+ id: format(date, "yyyy-MM-dd"),
586
+ label: format(date, "d"),
587
+ isCurrentMonth: isSameMonth(date, month),
588
+ isToday: getIsToday(date),
589
+ isWeekend: isWeekend(date),
590
+ ...daySelectionState(date, {
591
+ selectedDates,
592
+ selectedRange
593
+ }, {
594
+ minDate,
595
+ maxDate,
596
+ isDateDisabled
597
+ })
598
+ }))
599
+ })),
600
+ weekdays: rows[0].map((date) => ({
601
+ date,
602
+ label: format(date, "EEE", { locale })
603
+ }))
604
+ };
605
+ }
606
+ /**
607
+ * Headless month-grid hook. Returns the weeks and weekday headers for `month`,
608
+ * each day annotated with selection/disabled/today state, so you can render a
609
+ * fully custom calendar without reimplementing the date maths.
610
+ *
611
+ * ```tsx
612
+ * const { weeks, weekdays } = useMonthGrid(month, { selectedRange: range });
613
+ * // map weekdays -> header cells, weeks -> rows, days -> your own <DayCell />
614
+ * ```
615
+ */
616
+ function useMonthGrid(month, options = {}) {
617
+ const { weekStartsOn, showSixWeeks, isRTL, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
618
+ return useMemo(() => buildMonthGrid(month, options), [
619
+ month,
620
+ weekStartsOn,
621
+ showSixWeeks,
622
+ isRTL,
623
+ selectedDates,
624
+ selectedRange,
625
+ minDate,
626
+ maxDate,
627
+ isDateDisabled,
628
+ locale
629
+ ]);
630
+ }
631
+ //#endregion
632
+ //#region src/utils/recurrence.ts
633
+ const STEP = {
634
+ daily: addDays,
635
+ weekly: addWeeks,
636
+ monthly: addMonths,
637
+ yearly: addYears
638
+ };
639
+ const MAX_OCCURRENCES = 5e3;
640
+ function withTimeOf(date, source) {
641
+ const next = new Date(date);
642
+ next.setHours(source.getHours(), source.getMinutes(), source.getSeconds(), source.getMilliseconds());
643
+ return next;
644
+ }
645
+ function* occurrenceStarts(start, rule, rangeEnd) {
646
+ const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
647
+ let produced = 0;
648
+ const within = (date) => date.getTime() <= rangeEnd.getTime() && (rule.until == null || date.getTime() <= rule.until.getTime()) && (rule.count == null || produced < rule.count) && produced < MAX_OCCURRENCES;
649
+ if (rule.freq === "weekly" && rule.weekdays?.length) {
650
+ const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
651
+ let weekStart = startOfWeek(start, { weekStartsOn: 0 });
652
+ while (true) {
653
+ let advanced = false;
654
+ for (const weekday of weekdays) {
655
+ const date = withTimeOf(addDays(weekStart, weekday), start);
656
+ if (date.getTime() < start.getTime()) continue;
657
+ if (!within(date)) return;
658
+ produced += 1;
659
+ advanced = true;
660
+ yield date;
661
+ }
662
+ const nextWeek = addWeeks(weekStart, interval);
663
+ if (!advanced && nextWeek.getTime() > rangeEnd.getTime()) return;
664
+ weekStart = nextWeek;
665
+ }
666
+ }
667
+ let cursor = start;
668
+ while (within(cursor)) {
669
+ produced += 1;
670
+ yield cursor;
671
+ cursor = STEP[rule.freq](cursor, interval);
672
+ }
673
+ }
674
+ function instanceAt(event, start, durationMs) {
675
+ const instance = {
676
+ ...event,
677
+ start,
678
+ end: new Date(start.getTime() + durationMs)
679
+ };
680
+ delete instance.recurrence;
681
+ return instance;
682
+ }
683
+ /**
684
+ * Materialise recurring events into concrete occurrences overlapping
685
+ * `[rangeStart, rangeEnd]`. Non-recurring events pass through untouched, so the
686
+ * result is ready to hand to `<Calendar events={...} />`. Each occurrence keeps
687
+ * the original event's duration and fields (minus `recurrence`).
688
+ */
689
+ function expandRecurringEvents(events, rangeStart, rangeEnd) {
690
+ const out = [];
691
+ for (const event of events) {
692
+ if (!event.recurrence) {
693
+ out.push(event);
694
+ continue;
695
+ }
696
+ const durationMs = event.end.getTime() - event.start.getTime();
697
+ for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
698
+ if (start.getTime() + durationMs < rangeStart.getTime()) continue;
699
+ out.push(instanceAt(event, start, durationMs));
700
+ }
701
+ }
702
+ return out;
703
+ }
704
+ //#endregion
705
+ //#region src/utils/timezone.ts
706
+ /**
707
+ * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
708
+ * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
709
+ * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
710
+ * passing zoned dates makes it render in `timeZone` regardless of the device.
711
+ *
712
+ * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
713
+ * web). The result is for display/layout only; it no longer points at the
714
+ * original UTC instant, so don't round-trip it back to a real time.
715
+ */
716
+ function toZonedTime(date, timeZone) {
717
+ const parts = new Intl.DateTimeFormat("en-US", {
718
+ timeZone,
719
+ hourCycle: "h23",
720
+ year: "numeric",
721
+ month: "2-digit",
722
+ day: "2-digit",
723
+ hour: "2-digit",
724
+ minute: "2-digit",
725
+ second: "2-digit"
726
+ }).formatToParts(date);
727
+ const field = (type) => {
728
+ const part = parts.find((p) => p.type === type);
729
+ return part ? Number(part.value) : 0;
730
+ };
731
+ const hour = field("hour") % 24;
732
+ return new Date(field("year"), field("month") - 1, field("day"), hour, field("minute"), field("second"), date.getMilliseconds());
733
+ }
734
+ /**
735
+ * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
736
+ * displays them in `timeZone`. Other fields are preserved. Memoize the result
737
+ * (e.g. with `useMemo`) since it allocates new dates.
738
+ */
739
+ function eventsInTimeZone(events, timeZone) {
740
+ return events.map((event) => ({
741
+ ...event,
742
+ start: toZonedTime(event.start, timeZone),
743
+ end: toZonedTime(event.end, timeZone)
744
+ }));
745
+ }
746
+ //#endregion
747
+ export { CalendarSelectionProvider, MIN_BOX_HEIGHT_FOR_TIME, bandRounding, buildMonthGrid, buildMonthWeeks, cellRangeFromDrag, closedHourBands, compareDayEvents, darkColors, dayBadgeKind, daySelectionState, eventAccessibilityLabel, eventChipLayout, eventDayKeys, eventTimeLabel, eventsInTimeZone, expandRecurringEvents, formatHour, getIsToday, getViewDays, getWeekDays, groupEventsByDay, isAllDayEvent, isDateSelectable, isRangeEndpoint, isSameCalendarDay, isTimeVisibleAtHeight, isWeekend, isWithinDateRange, layoutDayEvents, lightColors, minutesIntoDay, monthEventCapacity, monthVisibleCount, nextDateRange, rangeBandKind, resolveDraggedBounds, shiftMinutes, snapDeltaMinutes, titleEllipsizeMode, titleNumberOfLines, toZonedTime, useCalendarSelection, useDateRange, useMonthGrid, viewDayCount, weekDaysCount };