@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.
@@ -0,0 +1,180 @@
1
+ import { format } from "date-fns";
2
+ import type { CalendarMode } from "../types";
3
+
4
+ /**
5
+ * Minimum event-box height (px) before the built-in renderer shows the time line
6
+ * on a narrow multi-column timed grid. Tied to the default theme's font sizes.
7
+ */
8
+ export const MIN_BOX_HEIGHT_FOR_TIME = 56;
9
+
10
+ /** Hard-clip an overflowing title by default; opt into a trailing ellipsis. */
11
+ export function titleEllipsizeMode(ellipsizeTitle: boolean): "clip" | "tail" {
12
+ return ellipsizeTitle ? "tail" : "clip";
13
+ }
14
+
15
+ /**
16
+ * Screen-reader label for an event: its title followed by "all day" or its time
17
+ * range (which the grid otherwise only conveys visually). Empty title is dropped.
18
+ */
19
+ export function eventAccessibilityLabel(args: {
20
+ title?: string;
21
+ isAllDay: boolean;
22
+ start: Date;
23
+ end: Date;
24
+ ampm: boolean;
25
+ /** Spoken text for an all-day event. Default "all day". */
26
+ allDayLabel?: string;
27
+ }): string {
28
+ const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
29
+ const time = args.isAllDay
30
+ ? (args.allDayLabel ?? "all day")
31
+ : `${format(args.start, timeFormat)} to ${format(args.end, timeFormat)}`;
32
+ return [args.title, time].filter(Boolean).join(", ");
33
+ }
34
+
35
+ /**
36
+ * Month cells and the all-day lane show a single clipped line; timed-grid titles
37
+ * (`undefined`) wrap to fill the box.
38
+ */
39
+ export function titleNumberOfLines(mode: CalendarMode, isAllDay: boolean): number | undefined {
40
+ return mode === "month" || isAllDay ? 1 : undefined;
41
+ }
42
+
43
+ /**
44
+ * The secondary line under the title in the built-in renderer, or `null` when
45
+ * none should show. Timed events get their `start - end` range. An all-day event
46
+ * gets the literal "All day" in the schedule (which has no all-day lane to
47
+ * signal it positionally) and nothing on the day/week grid (the lane already
48
+ * does). Month cells and `showTime={false}` always return `null`.
49
+ */
50
+ export function eventTimeLabel(args: {
51
+ mode: CalendarMode;
52
+ isAllDay: boolean;
53
+ start: Date;
54
+ end: Date;
55
+ ampm: boolean;
56
+ showTime: boolean;
57
+ /** Text for an all-day event in the schedule. Default "All day". */
58
+ allDayLabel?: string;
59
+ }): string | null {
60
+ if (!args.showTime || args.mode === "month") return null;
61
+ if (args.isAllDay) return args.mode === "schedule" ? (args.allDayLabel ?? "All day") : null;
62
+ const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
63
+ return `${format(args.start, timeFormat)} - ${format(args.end, timeFormat)}`;
64
+ }
65
+
66
+ /**
67
+ * The default hour-axis label shared by both renderers' time grids, so the gutter
68
+ * reads the same on each: 24-hour "HH:00" (e.g. "08:00"), or a compact 12-hour
69
+ * "h AM/PM" (e.g. "8 AM") when `ampm` is set. Exported so a custom hour renderer
70
+ * can reuse the same formatting.
71
+ */
72
+ export function formatHour(hour: number, opts?: { ampm?: boolean }): string {
73
+ if (opts?.ampm) {
74
+ const period = hour < 12 ? "AM" : "PM";
75
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
76
+ return `${hour12} ${period}`;
77
+ }
78
+ return `${String(hour).padStart(2, "0")}:00`;
79
+ }
80
+
81
+ /**
82
+ * Whether the time line fits in the box. The wide `day` column and contexts with
83
+ * no live box height (e.g. schedule, where `boxHeightPx` is undefined) always
84
+ * show it; narrow multi-column modes only once the box is at least
85
+ * {@link MIN_BOX_HEIGHT_FOR_TIME} tall. Runs on the UI thread inside the event
86
+ * renderer's animated style.
87
+ */
88
+ export function isTimeVisibleAtHeight(
89
+ boxHeightPx: number | undefined,
90
+ mode: CalendarMode,
91
+ ): boolean {
92
+ "worklet";
93
+ if (boxHeightPx == null || mode === "day") return true;
94
+ return boxHeightPx >= MIN_BOX_HEIGHT_FOR_TIME;
95
+ }
96
+
97
+ /** Layout for the built-in timed-grid event chip at a given box height. */
98
+ export type EventChipLayout = {
99
+ /**
100
+ * Max whole title lines that fit in the box. `0` means "no clamp" (the box
101
+ * height is unknown, e.g. the schedule), so the title may wrap freely.
102
+ */
103
+ titleMaxLines: number;
104
+ /** Whether the secondary time line still has room below the title. */
105
+ showTime: boolean;
106
+ };
107
+
108
+ /**
109
+ * Lay out the built-in timed-grid event chip for a box of `boxHeightPx`: how
110
+ * many whole title lines fit, and whether the time line still has room below
111
+ * them. The title is primary, so the title fills the box in whole lines (never a
112
+ * half-cropped line) and the time only shows once a full line is left over. Pass
113
+ * `titleLineHeightPx`/`timeLineHeightPx` matching the rendered line heights so
114
+ * the clamp lands on a line boundary.
115
+ *
116
+ * Worklet-safe, so the native renderer can drive the title's max-height on the UI
117
+ * thread as the grid zooms; the dom renderer calls it with its static box height.
118
+ * A `boxHeightPx` of `undefined` (the schedule has no live box height) returns
119
+ * `titleMaxLines: 0` (no clamp) with the unconditional time visibility.
120
+ */
121
+ export function eventChipLayout(args: {
122
+ boxHeightPx: number | undefined;
123
+ mode: CalendarMode;
124
+ hasTime: boolean;
125
+ titleLineHeightPx: number;
126
+ timeLineHeightPx: number;
127
+ paddingYPx: number;
128
+ }): EventChipLayout {
129
+ "worklet";
130
+ const wantTime = args.hasTime && isTimeVisibleAtHeight(args.boxHeightPx, args.mode);
131
+ if (args.boxHeightPx == null || args.titleLineHeightPx <= 0) {
132
+ return { titleMaxLines: 0, showTime: wantTime };
133
+ }
134
+ const inner = args.boxHeightPx - args.paddingYPx * 2;
135
+ // Reserve the time line first, then give the title every whole line that fits
136
+ // in what remains (always at least one line).
137
+ const titleMaxLines = Math.max(
138
+ 1,
139
+ Math.floor((inner - (wantTime ? args.timeLineHeightPx : 0)) / args.titleLineHeightPx),
140
+ );
141
+ // Only keep the time if a full line is still free under the clamped title, so a
142
+ // short box drops the time rather than slicing it in half.
143
+ const showTime =
144
+ wantTime && inner - titleMaxLines * args.titleLineHeightPx >= args.timeLineHeightPx;
145
+ return { titleMaxLines, showTime };
146
+ }
147
+
148
+ /** How many month-cell chips fit in the available height. */
149
+ export type MonthEventCapacity = {
150
+ /** Count when every event fits, with no overflow label. */
151
+ full: number;
152
+ /** Count that leaves room for the "+N more" label. */
153
+ withMore: number;
154
+ };
155
+
156
+ /**
157
+ * Derive how many event chips fit in a month cell from the measured space.
158
+ * `chipRowHeightPx` is one chip plus its gap; `moreRowHeightPx` is the overflow
159
+ * label plus its gap. Both counts are clamped to >= 0.
160
+ */
161
+ export function monthEventCapacity(
162
+ availableHeightPx: number,
163
+ chipRowHeightPx: number,
164
+ moreRowHeightPx: number,
165
+ ): MonthEventCapacity {
166
+ if (chipRowHeightPx <= 0) return { full: 0, withMore: 0 };
167
+ return {
168
+ full: Math.max(0, Math.floor(availableHeightPx / chipRowHeightPx)),
169
+ withMore: Math.max(0, Math.floor((availableHeightPx - moreRowHeightPx) / chipRowHeightPx)),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Chips to show for a day: all of them when they fit, otherwise `withMore` (at
175
+ * least one) so the rest collapse into a "+N more" label.
176
+ */
177
+ export function monthVisibleCount(total: number, capacity: MonthEventCapacity): number {
178
+ if (total <= capacity.full) return total;
179
+ return Math.max(1, capacity.withMore);
180
+ }
@@ -0,0 +1,193 @@
1
+ import { addDays, differenceInMinutes, max as maxDate, min as minDate, startOfDay } from "date-fns";
2
+ import type { BusinessHours, CalendarEvent } from "../types";
3
+
4
+ const MINUTES_PER_HOUR = 60;
5
+ // Minimum duration (in hours) a positioned event is given, so a zero/negative
6
+ // span still occupies a sliver rather than collapsing to nothing.
7
+ const MIN_DURATION_HOURS = 0.25;
8
+
9
+ export type PositionedEvent<T> = {
10
+ event: CalendarEvent<T>;
11
+ /** Hours from midnight to the event's segment start on this day (fractional). */
12
+ startHours: number;
13
+ /** Segment duration in hours on this day (clamped to a small minimum). */
14
+ durationHours: number;
15
+ /** Zero-based column index within its overlap cluster. */
16
+ column: number;
17
+ /** Total columns in this event's overlap cluster. */
18
+ columns: number;
19
+ /** True when the segment is clipped because the event continues before/after this day. */
20
+ continuesBefore: boolean;
21
+ continuesAfter: boolean;
22
+ };
23
+
24
+ type Segment<T> = {
25
+ event: CalendarEvent<T>;
26
+ start: number;
27
+ end: number;
28
+ continuesBefore: boolean;
29
+ continuesAfter: boolean;
30
+ };
31
+
32
+ /**
33
+ * Lay out a single day's events: events that overlap in time are split into
34
+ * side-by-side columns. Multi-day events are clipped to the portion that falls
35
+ * on `day` (e.g. a 23:00→01:00 event renders 23:00–24:00 on the start day and
36
+ * 00:00–01:00 on the next). Pure — safe to call per render, never per frame.
37
+ */
38
+ export function layoutDayEvents<T>(events: CalendarEvent<T>[], day: Date): PositionedEvent<T>[] {
39
+ const dayStart = startOfDay(day);
40
+ const nextDayStart = addDays(dayStart, 1);
41
+
42
+ const segments: Segment<T>[] = events
43
+ // All-day events live in the lane, not the timed columns.
44
+ .filter((event) => !isAllDayEvent(event))
45
+ // Overlaps this day if it starts before the day ends and ends after it begins.
46
+ .filter((event) => event.start < nextDayStart && event.end > dayStart)
47
+ .map((event) => {
48
+ const segStart = maxDate([event.start, dayStart]);
49
+ const segEnd = minDate([event.end, nextDayStart]);
50
+ return {
51
+ event,
52
+ start: differenceInMinutes(segStart, dayStart) / MINUTES_PER_HOUR,
53
+ end: differenceInMinutes(segEnd, dayStart) / MINUTES_PER_HOUR,
54
+ continuesBefore: event.start < dayStart,
55
+ continuesAfter: event.end > nextDayStart,
56
+ };
57
+ })
58
+ .sort((a, b) => a.start - b.start);
59
+
60
+ const positioned: PositionedEvent<T>[] = [];
61
+ let cluster: Segment<T>[] = [];
62
+ let clusterEnd = Number.NEGATIVE_INFINITY;
63
+
64
+ const flushCluster = () => {
65
+ const columnEnds: number[] = [];
66
+ const columnOf = new Map<Segment<T>, number>();
67
+ for (const seg of cluster) {
68
+ let column = columnEnds.findIndex((end) => end <= seg.start);
69
+ if (column === -1) {
70
+ column = columnEnds.length;
71
+ columnEnds.push(seg.end);
72
+ } else {
73
+ columnEnds[column] = seg.end;
74
+ }
75
+ columnOf.set(seg, column);
76
+ }
77
+ for (const seg of cluster) {
78
+ positioned.push({
79
+ event: seg.event,
80
+ startHours: seg.start,
81
+ durationHours: Math.max(seg.end - seg.start, MIN_DURATION_HOURS),
82
+ column: columnOf.get(seg) ?? 0,
83
+ columns: columnEnds.length,
84
+ continuesBefore: seg.continuesBefore,
85
+ continuesAfter: seg.continuesAfter,
86
+ });
87
+ }
88
+ cluster = [];
89
+ };
90
+
91
+ for (const seg of segments) {
92
+ if (cluster.length > 0 && seg.start >= clusterEnd) flushCluster();
93
+ cluster.push(seg);
94
+ clusterEnd = Math.max(clusterEnd, seg.end);
95
+ }
96
+ if (cluster.length > 0) flushCluster();
97
+
98
+ return positioned;
99
+ }
100
+
101
+ const atMidnight = (date: Date): boolean =>
102
+ date.getHours() === 0 &&
103
+ date.getMinutes() === 0 &&
104
+ date.getSeconds() === 0 &&
105
+ date.getMilliseconds() === 0;
106
+
107
+ /**
108
+ * Whether an event belongs in the all-day lane. An explicit `allDay` flag wins;
109
+ * otherwise it's inferred when the event spans whole days (both `start` and
110
+ * `end` land on midnight, e.g. an iCal-style all-day event). Pure.
111
+ */
112
+ export function isAllDayEvent<T>(event: CalendarEvent<T>): boolean {
113
+ if (typeof event.allDay === "boolean") return event.allDay;
114
+ return event.end > event.start && atMidnight(event.start) && atMidnight(event.end);
115
+ }
116
+
117
+ /**
118
+ * The `startOfDay` ISO keys of every calendar day an event touches (inclusive).
119
+ * An event ending exactly at midnight does not count the following day. Used to
120
+ * index events by day for the month grid. Pure.
121
+ */
122
+ export function eventDayKeys<T>(event: CalendarEvent<T>): string[] {
123
+ const first = startOfDay(event.start);
124
+ // The last instant the event occupies; an end of exactly midnight belongs to
125
+ // the previous day.
126
+ const lastInstant = event.end > event.start ? new Date(event.end.getTime() - 1) : event.start;
127
+ const last = startOfDay(lastInstant);
128
+
129
+ const keys: string[] = [];
130
+ for (let cursor = first; cursor <= last; cursor = addDays(cursor, 1)) {
131
+ keys.push(cursor.toISOString());
132
+ }
133
+ return keys;
134
+ }
135
+
136
+ /**
137
+ * Index events by the `startOfDay` ISO key of every day they touch (via
138
+ * {@link eventDayKeys}), so a month grid can look up a day's events with
139
+ * `startOfDay(date).toISOString()`. Built once and shared across month cells.
140
+ */
141
+ export function groupEventsByDay<T>(
142
+ events: readonly CalendarEvent<T>[],
143
+ ): Map<string, CalendarEvent<T>[]> {
144
+ const map = new Map<string, CalendarEvent<T>[]>();
145
+ for (const event of events) {
146
+ for (const key of eventDayKeys(event)) {
147
+ const list = map.get(key);
148
+ if (list) list.push(event);
149
+ else map.set(key, [event]);
150
+ }
151
+ }
152
+ return map;
153
+ }
154
+
155
+ /**
156
+ * Order a day's events for the month and list views: all-day events come first
157
+ * (they head the day regardless of their start time), then timed events by start.
158
+ * Shared by both renderers so the order is identical. Use as an `Array.sort`
159
+ * comparator.
160
+ */
161
+ export function compareDayEvents<T>(a: CalendarEvent<T>, b: CalendarEvent<T>): number {
162
+ const aAllDay = isAllDayEvent(a);
163
+ const bAllDay = isAllDayEvent(b);
164
+ if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
165
+ return a.start.getTime() - b.start.getTime();
166
+ }
167
+
168
+ /**
169
+ * The closed hour-spans of a day to shade on the time grid, given a
170
+ * `businessHours` callback and the visible `[minHour, maxHour]` window: the spans
171
+ * before open and after close (clamped to the window), the whole window when the
172
+ * day is closed (`null`) or the open hours are inverted/empty, or none when the
173
+ * callback returns `undefined`. Shared by both renderers so shading stays
174
+ * identical. Co-located with `groupEventsByDay`; both feed the grid layout.
175
+ */
176
+ export function closedHourBands(
177
+ day: Date,
178
+ businessHours: BusinessHours | undefined,
179
+ minHour = 0,
180
+ maxHour = 24,
181
+ ): { start: number; end: number }[] {
182
+ const open = businessHours?.(day);
183
+ if (open === undefined) return [];
184
+ if (open === null) return [{ start: minHour, end: maxHour }];
185
+ const start = Math.max(minHour, Math.min(maxHour, open.start));
186
+ const end = Math.max(minHour, Math.min(maxHour, open.end));
187
+ // Inverted or empty open hours mean nothing is open: shade the whole window.
188
+ if (start >= end) return [{ start: minHour, end: maxHour }];
189
+ const bands: { start: number; end: number }[] = [];
190
+ if (start > minHour) bands.push({ start: minHour, end: start });
191
+ if (end < maxHour) bands.push({ start: end, end: maxHour });
192
+ return bands;
193
+ }
@@ -0,0 +1,144 @@
1
+ import { format, type Locale, isSameMonth } from "date-fns";
2
+ import { useMemo } from "react";
3
+ import type { WeekStartsOn } from "../types";
4
+ import { type DateRange, type DateSelectionConstraints, daySelectionState } from "./dateRange";
5
+ import { buildMonthWeeks, getIsToday, isWeekend } from "./dates";
6
+
7
+ /** A single day in the grid, with all the state a custom cell needs to render. */
8
+ export interface MonthGridDay {
9
+ date: Date;
10
+ /** Stable `yyyy-MM-dd` id, handy as a React key. */
11
+ id: string;
12
+ /** Day-of-month, e.g. "1". */
13
+ label: string;
14
+ isCurrentMonth: boolean;
15
+ isToday: boolean;
16
+ isWeekend: boolean;
17
+ isDisabled: boolean;
18
+ isSelected: boolean;
19
+ isRangeStart: boolean;
20
+ isRangeEnd: boolean;
21
+ /** Inside a complete range (endpoints included). */
22
+ isInRange: boolean;
23
+ }
24
+
25
+ /** One week row. */
26
+ export interface MonthGridWeek {
27
+ id: string;
28
+ days: MonthGridDay[];
29
+ }
30
+
31
+ /** A weekday header cell (e.g. "Mon"). */
32
+ export interface MonthGridWeekday {
33
+ date: Date;
34
+ label: string;
35
+ }
36
+
37
+ export interface MonthGrid {
38
+ weeks: MonthGridWeek[];
39
+ weekdays: MonthGridWeekday[];
40
+ }
41
+
42
+ export interface UseMonthGridOptions extends DateSelectionConstraints {
43
+ /** First day of the week. Sunday = 0 (default) … Saturday = 6. */
44
+ weekStartsOn?: WeekStartsOn;
45
+ /** Always return six week rows for a fixed-height grid. Default false. */
46
+ showSixWeeks?: boolean;
47
+ /** Reverse each week's day order (right-to-left). Default false. */
48
+ isRTL?: boolean;
49
+ /** Selected discrete days (single/multiple). */
50
+ selectedDates?: Date[];
51
+ /** Selected span. */
52
+ selectedRange?: DateRange;
53
+ /** A date-fns locale for the weekday labels. */
54
+ locale?: Locale;
55
+ }
56
+
57
+ /**
58
+ * Pure month-grid builder: the weeks and weekday headers for `month`, each day
59
+ * annotated with selection/disabled/today state. Use this when you need the
60
+ * data outside React; inside a component prefer {@link useMonthGrid}.
61
+ */
62
+ export function buildMonthGrid(month: Date, options: UseMonthGridOptions = {}): MonthGrid {
63
+ const {
64
+ weekStartsOn = 0,
65
+ showSixWeeks = false,
66
+ isRTL = false,
67
+ selectedDates,
68
+ selectedRange,
69
+ minDate,
70
+ maxDate,
71
+ isDateDisabled,
72
+ locale,
73
+ } = options;
74
+
75
+ const rows = buildMonthWeeks(month, weekStartsOn, { showSixWeeks, isRTL });
76
+
77
+ const weeks: MonthGridWeek[] = rows.map((days) => ({
78
+ id: days[0].toISOString(),
79
+ days: days.map(
80
+ (date): MonthGridDay => ({
81
+ date,
82
+ id: format(date, "yyyy-MM-dd"),
83
+ label: format(date, "d"),
84
+ isCurrentMonth: isSameMonth(date, month),
85
+ isToday: getIsToday(date),
86
+ isWeekend: isWeekend(date),
87
+ // Shared with MonthView, so the headless grid matches the built-in view.
88
+ ...daySelectionState(
89
+ date,
90
+ { selectedDates, selectedRange },
91
+ { minDate, maxDate, isDateDisabled },
92
+ ),
93
+ }),
94
+ ),
95
+ }));
96
+
97
+ // Weekday labels depend only on the first row's dates (already ordered).
98
+ const weekdays: MonthGridWeekday[] = rows[0].map((date) => ({
99
+ date,
100
+ label: format(date, "EEE", { locale }),
101
+ }));
102
+
103
+ return { weeks, weekdays };
104
+ }
105
+
106
+ /**
107
+ * Headless month-grid hook. Returns the weeks and weekday headers for `month`,
108
+ * each day annotated with selection/disabled/today state, so you can render a
109
+ * fully custom calendar without reimplementing the date maths.
110
+ *
111
+ * ```tsx
112
+ * const { weeks, weekdays } = useMonthGrid(month, { selectedRange: range });
113
+ * // map weekdays -> header cells, weeks -> rows, days -> your own <DayCell />
114
+ * ```
115
+ */
116
+ export function useMonthGrid(month: Date, options: UseMonthGridOptions = {}): MonthGrid {
117
+ const {
118
+ weekStartsOn,
119
+ showSixWeeks,
120
+ isRTL,
121
+ selectedDates,
122
+ selectedRange,
123
+ minDate,
124
+ maxDate,
125
+ isDateDisabled,
126
+ locale,
127
+ } = options;
128
+ return useMemo(
129
+ () => buildMonthGrid(month, options),
130
+ // Deps are the destructured option fields, not the (unstable) options object.
131
+ [
132
+ month,
133
+ weekStartsOn,
134
+ showSixWeeks,
135
+ isRTL,
136
+ selectedDates,
137
+ selectedRange,
138
+ minDate,
139
+ maxDate,
140
+ isDateDisabled,
141
+ locale,
142
+ ],
143
+ );
144
+ }
@@ -0,0 +1,98 @@
1
+ import { addDays, addMonths, addWeeks, addYears, startOfWeek } from "date-fns";
2
+ import type { CalendarEvent, RecurrenceFrequency, RecurrenceRule } from "../types";
3
+
4
+ const STEP: Record<RecurrenceFrequency, (date: Date, amount: number) => Date> = {
5
+ daily: addDays,
6
+ weekly: addWeeks,
7
+ monthly: addMonths,
8
+ yearly: addYears,
9
+ };
10
+
11
+ // Runaway guard. Generous enough for realistic ranges (e.g. ~13 years of a daily
12
+ // event); set `count`/`until`, or query a tighter range, for anything larger.
13
+ const MAX_OCCURRENCES = 5000;
14
+
15
+ // Copy `source`'s time of day onto `date`.
16
+ function withTimeOf(date: Date, source: Date): Date {
17
+ const next = new Date(date);
18
+ next.setHours(
19
+ source.getHours(),
20
+ source.getMinutes(),
21
+ source.getSeconds(),
22
+ source.getMilliseconds(),
23
+ );
24
+ return next;
25
+ }
26
+
27
+ // Occurrence start dates from `event.start` forward, in chronological order, up
28
+ // to `rangeEnd` (and the rule's own `count`/`until`).
29
+ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): Generator<Date> {
30
+ const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
31
+ let produced = 0;
32
+ const within = (date: Date) =>
33
+ date.getTime() <= rangeEnd.getTime() &&
34
+ (rule.until == null || date.getTime() <= rule.until.getTime()) &&
35
+ (rule.count == null || produced < rule.count) &&
36
+ produced < MAX_OCCURRENCES;
37
+
38
+ if (rule.freq === "weekly" && rule.weekdays?.length) {
39
+ const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
40
+ let weekStart = startOfWeek(start, { weekStartsOn: 0 });
41
+ while (true) {
42
+ let advanced = false;
43
+ for (const weekday of weekdays) {
44
+ const date = withTimeOf(addDays(weekStart, weekday), start);
45
+ if (date.getTime() < start.getTime()) continue; // before the first occurrence
46
+ if (!within(date)) return;
47
+ produced += 1;
48
+ advanced = true;
49
+ yield date;
50
+ }
51
+ const nextWeek = addWeeks(weekStart, interval);
52
+ // Guard against a week that yielded nothing yet hasn't reached the range.
53
+ if (!advanced && nextWeek.getTime() > rangeEnd.getTime()) return;
54
+ weekStart = nextWeek;
55
+ }
56
+ }
57
+
58
+ let cursor = start;
59
+ while (within(cursor)) {
60
+ produced += 1;
61
+ yield cursor;
62
+ cursor = STEP[rule.freq](cursor, interval);
63
+ }
64
+ }
65
+
66
+ function instanceAt<T>(event: CalendarEvent<T>, start: Date, durationMs: number): CalendarEvent<T> {
67
+ const instance = { ...event, start, end: new Date(start.getTime() + durationMs) };
68
+ // Occurrences aren't themselves recurring.
69
+ delete (instance as { recurrence?: unknown }).recurrence;
70
+ return instance;
71
+ }
72
+
73
+ /**
74
+ * Materialise recurring events into concrete occurrences overlapping
75
+ * `[rangeStart, rangeEnd]`. Non-recurring events pass through untouched, so the
76
+ * result is ready to hand to `<Calendar events={...} />`. Each occurrence keeps
77
+ * the original event's duration and fields (minus `recurrence`).
78
+ */
79
+ export function expandRecurringEvents<T>(
80
+ events: CalendarEvent<T>[],
81
+ rangeStart: Date,
82
+ rangeEnd: Date,
83
+ ): CalendarEvent<T>[] {
84
+ const out: CalendarEvent<T>[] = [];
85
+ for (const event of events) {
86
+ if (!event.recurrence) {
87
+ out.push(event);
88
+ continue;
89
+ }
90
+ const durationMs = event.end.getTime() - event.start.getTime();
91
+ for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
92
+ // Skip occurrences that end before the range opens, but keep iterating.
93
+ if (start.getTime() + durationMs < rangeStart.getTime()) continue;
94
+ out.push(instanceAt(event, start, durationMs));
95
+ }
96
+ }
97
+ return out;
98
+ }
@@ -0,0 +1,57 @@
1
+ import type { CalendarEvent } from "../types";
2
+
3
+ /**
4
+ * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
5
+ * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
6
+ * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
7
+ * passing zoned dates makes it render in `timeZone` regardless of the device.
8
+ *
9
+ * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
10
+ * web). The result is for display/layout only; it no longer points at the
11
+ * original UTC instant, so don't round-trip it back to a real time.
12
+ */
13
+ export function toZonedTime(date: Date, timeZone: string): Date {
14
+ const parts = new Intl.DateTimeFormat("en-US", {
15
+ timeZone,
16
+ hourCycle: "h23",
17
+ year: "numeric",
18
+ month: "2-digit",
19
+ day: "2-digit",
20
+ hour: "2-digit",
21
+ minute: "2-digit",
22
+ second: "2-digit",
23
+ }).formatToParts(date);
24
+
25
+ const field = (type: Intl.DateTimeFormatPartTypes): number => {
26
+ const part = parts.find((p) => p.type === type);
27
+ return part ? Number(part.value) : 0;
28
+ };
29
+
30
+ // `h23` can report midnight as 24; normalise to 0.
31
+ const hour = field("hour") % 24;
32
+ return new Date(
33
+ field("year"),
34
+ field("month") - 1,
35
+ field("day"),
36
+ hour,
37
+ field("minute"),
38
+ field("second"),
39
+ date.getMilliseconds(),
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
45
+ * displays them in `timeZone`. Other fields are preserved. Memoize the result
46
+ * (e.g. with `useMemo`) since it allocates new dates.
47
+ */
48
+ export function eventsInTimeZone<T>(
49
+ events: CalendarEvent<T>[],
50
+ timeZone: string,
51
+ ): CalendarEvent<T>[] {
52
+ return events.map((event) => ({
53
+ ...event,
54
+ start: toZonedTime(event.start, timeZone),
55
+ end: toZonedTime(event.end, timeZone),
56
+ }));
57
+ }