@lotics/ui 46.14.0 → 47.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.
@@ -1,78 +1,66 @@
1
- import { useMemo, useRef, type ReactNode, type Ref } from "react";
2
- import { View, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
5
  import { FocusRingPressable } from "../focus_ring_pressable";
6
+ import { Popover, PopoverTrigger, PopoverContent } from "../popover";
7
+ import { useCalendar } from "./context";
8
+ import { EventChip } from "./event_chip";
9
+ import { compareEvents, fitLanes, isBanner, packEventLanes } from "./layout";
10
+ import { addDays, dayDiff, dayHeading, daysInView, isSameMonth, isToday, startOfDay, weekdayShort } from "./dates";
11
+ import type { CalendarEvent } from "./types";
6
12
 
7
- function MonthPressable(props: {
8
- onPress?: () => void;
9
- accessibilityLabel?: string;
10
- style: StyleProp<ViewStyle>;
11
- pressableRef?: Ref<View>;
12
- children: ReactNode;
13
- }) {
14
- return (
15
- <FocusRingPressable
16
- ref={props.pressableRef}
17
- onPress={props.onPress}
18
- accessibilityRole={props.onPress ? "button" : undefined}
19
- accessibilityLabel={props.accessibilityLabel}
20
- style={props.style}
21
- >
22
- {props.children}
23
- </FocusRingPressable>
24
- );
25
- }
26
- import { usePointerDrag } from "../use_pointer_drag";
27
- import { packEventLanes } from "./layout";
28
- import { addDays, dayDiff, daysInView, isSameMonth, isToday, startOfDay, weekdayShort } from "./dates";
29
- import type { CalendarEvent, Weekday } from "./types";
30
-
31
- const LANE_H = 19;
13
+ const LANE_H = 20;
32
14
  const LANE_GAP = 2;
33
- const MAX_LANES = 3; // visible event lanes per week before "+N more"
15
+ const DATE_ROW_H = 26;
16
+ const OVERFLOW_CHIP_H = 17;
17
+ const WEEKDAY_HEADER_H = 28;
34
18
 
35
- export interface MonthViewProps<T = unknown> {
36
- date: Date;
37
- events: CalendarEvent<T>[];
38
- weekStartsOn?: Weekday;
39
- locale?: string;
40
- /** Overflow chip label, e.g. (3) => "+3 more". Defaults to English. */
41
- moreLabel?: (count: number) => string;
42
- onEventPress?: (event: CalendarEvent<T>) => void;
43
- onDayPress?: (day: Date) => void;
44
- /** When set, event chips become draggable; dropping on another day reschedules
45
- * the event, preserving its duration. Receives the event + new start/end. */
46
- onEventDrop?: (event: CalendarEvent<T>, newStart: Date, newEnd: Date | null) => void;
47
- }
19
+ /**
20
+ * The height below which a month grid stops being able to say anything.
21
+ *
22
+ * Six rows that can hold the date, one event and the overflow chip. Under this
23
+ * every row fits the chip alone, and the grid degenerates into a heat map of
24
+ * "+3 more" with not one event named — which is a worse answer than the agenda,
25
+ * so the root falls back to it on height exactly as it does on width.
26
+ */
27
+ export const MIN_MONTH_HEIGHT = 6 * (DATE_ROW_H + LANE_H + OVERFLOW_CHIP_H) + WEEKDAY_HEADER_H;
28
+
29
+ /**
30
+ * The month grid.
31
+ *
32
+ * Two things it decides for you, because getting either wrong is invisible until
33
+ * a real month arrives:
34
+ *
35
+ * **How many week rows.** As many as the month needs (4–6), not a fixed 42 days.
36
+ * A constant six spends a whole row on greyed-out next-month dates in most
37
+ * months.
38
+ *
39
+ * **How many event lanes.** Measured, not constant. The row is `flex: 1`, so its
40
+ * height is whatever the calendar's height divided by the week count happens to
41
+ * be — a fixed cap either wastes a tall row or draws lanes straight through a
42
+ * short one's neighbours. We measure and fit, and give one lane back to the
43
+ * "+N more" chip when there is overflow.
44
+ */
45
+ export function CalendarMonth() {
46
+ const {
47
+ events, date, setDate, setView, weekStartsOn, locale, labels, now, onEventPress, onSlotPress, renderEvent,
48
+ } = useCalendar();
48
49
 
49
- export function MonthView<T = unknown>(props: MonthViewProps<T>) {
50
- const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress, onEventDrop } = props;
51
50
  const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
52
- const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
51
+ const weeks = useMemo(
52
+ () => Array.from({ length: days.length / 7 }, (_, w) => days.slice(w * 7, w * 7 + 7)),
53
+ [days],
54
+ );
53
55
  const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
54
- const now = new Date();
55
56
 
56
- // Drag-to-reschedule: each week row registers its DOM rect; on drop we map the
57
- // pointer to a (week, column) day, then shift the event by whole days.
58
- const eventById = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
59
- const weekNodes = useRef<Map<number, HTMLElement>>(new Map());
60
- const { live, bind } = usePointerDrag((id, pointer) => {
61
- const ev = eventById.get(id);
62
- if (!ev || !onEventDrop) return;
63
- for (const [w, el] of weekNodes.current) {
64
- const r = el.getBoundingClientRect();
65
- if (r.width > 0 && pointer.y >= r.top && pointer.y <= r.bottom) {
66
- const col = Math.min(6, Math.max(0, Math.floor((pointer.x - r.left) / (r.width / 7))));
67
- const target = days[w * 7 + col];
68
- if (!target) return;
69
- const shift = dayDiff(startOfDay(ev.start), target);
70
- if (shift === 0) return;
71
- onEventDrop(ev, addDays(ev.start, shift), ev.end ? addDays(ev.end, shift) : null);
72
- return;
73
- }
74
- }
75
- });
57
+ // One measurement for every row: the rows are equal-height siblings, so a
58
+ // per-row measurement would be six identical numbers and six extra renders.
59
+ const [laneAreaHeight, setLaneAreaHeight] = useState(0);
60
+ const onRowLayout = (e: LayoutChangeEvent) => {
61
+ const next = Math.round(e.nativeEvent.layout.height - DATE_ROW_H);
62
+ setLaneAreaHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
63
+ };
76
64
 
77
65
  return (
78
66
  <View style={styles.root}>
@@ -85,115 +73,97 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
85
73
  </View>
86
74
 
87
75
  {weeks.map((weekDays, w) => {
88
- const { bars } = packEventLanes(events, weekDays[0], 7);
89
- const visible = bars.filter((b) => b.lane < MAX_LANES);
90
- const overflow = Array.from({ length: 7 }, () => 0);
76
+ const { bars, lanes } = packEventLanes(events, weekDays[0], 7);
77
+ const { visibleLanes, overflows } = fitLanes(laneAreaHeight, LANE_H, LANE_GAP, lanes, OVERFLOW_CHIP_H);
78
+ const visible = bars.filter((b) => b.lane < visibleLanes);
79
+
80
+ // What each day still has to show. Counted per DAY, not per bar: a
81
+ // five-day bar that did not fit is one hidden thing on each of the five
82
+ // days it covers, and a reader looking at Wednesday wants Wednesday's
83
+ // number.
84
+ const hidden = Array.from({ length: 7 }, () => 0);
91
85
  for (const b of bars) {
92
- if (b.lane >= MAX_LANES) {
93
- for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) overflow[c]++;
94
- }
86
+ if (b.lane < visibleLanes) continue;
87
+ for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) hidden[c]++;
95
88
  }
96
89
 
97
90
  return (
98
- <View
99
- key={w}
100
- style={styles.weekRow}
101
- ref={(node) => {
102
- const el = node as unknown as HTMLElement | null;
103
- if (el) weekNodes.current.set(w, el);
104
- else weekNodes.current.delete(w);
105
- }}
106
- >
107
- {/* Date numbers */}
91
+ <View key={w} style={styles.weekRow} onLayout={w === 0 ? onRowLayout : undefined}>
108
92
  <View style={styles.dateRow}>
109
93
  {weekDays.map((day) => {
110
94
  const inMonth = isSameMonth(day, date);
111
95
  const today = isToday(day, now);
112
96
  return (
113
- <MonthPressable
97
+ <FocusRingPressable
114
98
  key={day.toISOString()}
115
- onPress={onDayPress ? () => onDayPress(day) : undefined}
99
+ onPress={onSlotPress ? () => onSlotPress(day, addDays(day, 1)) : undefined}
100
+ accessibilityRole={onSlotPress ? "button" : undefined}
101
+ accessibilityLabel={dayHeading(day, locale)}
116
102
  style={styles.dateCell}
117
103
  >
118
- <View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
104
+ <View style={[styles.dateBadge, today && styles.dateBadgeToday]}>
119
105
  <Text
120
106
  size="xs"
121
107
  weight={today ? "semibold" : "regular"}
122
- style={{ color: today ? colors.white : inMonth ? colors.zinc[800] : colors.zinc[400] }}
108
+ style={today ? styles.dateToday : inMonth ? styles.dateIn : styles.dateOut}
123
109
  >
124
110
  {day.getDate()}
125
111
  </Text>
126
112
  </View>
127
- </MonthPressable>
113
+ </FocusRingPressable>
128
114
  );
129
115
  })}
130
116
  </View>
131
117
 
132
- {/* Event lanes */}
133
118
  <View style={styles.laneArea}>
134
119
  {weekDays.map((_, i) =>
135
120
  i === 0 ? null : (
136
- <View
137
- key={i}
138
- style={{ position: "absolute", left: `${(i / 7) * 100}%`, top: -2, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] }}
139
- />
121
+ <View key={i} style={[styles.colRule, { left: `${(i / 7) * 100}%` }]} />
140
122
  ),
141
123
  )}
142
- {visible.map((bar) => {
143
- const accent = bar.event.color || colors.teal[600];
144
- const banner = bar.span > 1 || !!bar.event.allDay;
145
- const drag = live && live.id === bar.event.id ? live : null;
146
- return (
147
- <MonthPressable
148
- key={bar.event.id}
149
- pressableRef={onEventDrop ? bind(bar.event.id, "grab") : undefined}
150
- onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
151
- accessibilityLabel={bar.event.title}
152
- style={{
153
- position: "absolute",
154
- top: bar.lane * (LANE_H + LANE_GAP),
155
- height: LANE_H,
156
- left: `${(bar.startCol / 7) * 100}%`,
157
- width: `${(bar.span / 7) * 100}%`,
158
- paddingHorizontal: 2,
159
- ...(drag ? { transform: [{ translateX: drag.dx }, { translateY: drag.dy }], zIndex: 20, opacity: 0.9 } : null),
160
- }}
161
- >
162
- <View
163
- style={{
164
- flex: 1,
165
- borderRadius: 4,
166
- backgroundColor: banner ? accent : "transparent",
167
- flexDirection: "row",
168
- alignItems: "center",
169
- paddingHorizontal: 5,
170
- gap: 5,
171
- }}
172
- >
173
- {!banner ? <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: accent }} /> : null}
174
- <Text
175
- size="xs"
176
- weight={banner ? "medium" : "regular"}
177
- numberOfLines={1}
178
- style={{ color: banner ? colors.white : colors.zinc[700], flex: 1 }}
179
- >
180
- {bar.event.title}
181
- </Text>
182
- </View>
183
- </MonthPressable>
184
- );
185
- })}
186
- {weekDays.map((day, col) =>
187
- overflow[col] > 0 ? (
188
- <MonthPressable
189
- key={`o${col}`}
190
- onPress={onDayPress ? () => onDayPress(day) : undefined}
191
- style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
192
- >
193
- <Text size="xs" color="muted">{moreLabel(overflow[col])}</Text>
194
- </MonthPressable>
195
- ) : null,
196
- )}
124
+ {visible.map((bar) => (
125
+ <EventChip
126
+ key={bar.event.id}
127
+ event={bar.event}
128
+ variant={isBanner(bar.event) ? "banner" : "dot"}
129
+ showTime={!isBanner(bar.event)}
130
+ height={LANE_H}
131
+ locale={locale}
132
+ render={renderEvent}
133
+ onPress={onEventPress ? () => onEventPress(bar.event.id) : undefined}
134
+ style={{
135
+ position: "absolute",
136
+ top: bar.lane * (LANE_H + LANE_GAP),
137
+ height: LANE_H,
138
+ left: `${(bar.startCol / 7) * 100}%`,
139
+ width: `${(bar.span / 7) * 100}%`,
140
+ paddingHorizontal: 2,
141
+ }}
142
+ />
143
+ ))}
144
+ {overflows
145
+ ? weekDays.map((day, col) =>
146
+ hidden[col] > 0 ? (
147
+ <DayOverflow
148
+ key={`o${col}`}
149
+ day={day}
150
+ col={col}
151
+ top={visibleLanes * (LANE_H + LANE_GAP)}
152
+ events={events}
153
+ label={labels.more(hidden[col])}
154
+ heading={dayHeading(day, locale)}
155
+ locale={locale}
156
+ renderEvent={renderEvent}
157
+ onEventPress={onEventPress}
158
+ onOpenDay={() => {
159
+ setDate(day);
160
+ setView("day");
161
+ }}
162
+ openDayLabel={labels.day}
163
+ />
164
+ ) : null,
165
+ )
166
+ : null}
197
167
  </View>
198
168
  </View>
199
169
  );
@@ -202,13 +172,87 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
202
172
  );
203
173
  }
204
174
 
175
+ /**
176
+ * The "+N more" chip and what it opens.
177
+ *
178
+ * A POPOVER of that day's events, not a jump to day view. Switching views to
179
+ * read three more titles costs the reader the month they were looking at, and
180
+ * getting back means finding the month button and then the date again.
181
+ */
182
+ function DayOverflow(props: {
183
+ day: Date;
184
+ col: number;
185
+ top: number;
186
+ events: CalendarEvent<unknown>[];
187
+ label: string;
188
+ heading: string;
189
+ locale?: string;
190
+ renderEvent?: (event: CalendarEvent<unknown>) => ReactNode;
191
+ onEventPress?: (id: string) => void;
192
+ onOpenDay: () => void;
193
+ openDayLabel: string;
194
+ }) {
195
+ const dayEvents = useMemo(
196
+ () =>
197
+ props.events
198
+ .filter((e) => {
199
+ const from = dayDiff(startOfDay(e.start), props.day);
200
+ const to = dayDiff(props.day, startOfDay(e.end ?? e.start));
201
+ return from >= 0 && to >= 0;
202
+ })
203
+ .sort(compareEvents),
204
+ [props.events, props.day],
205
+ );
206
+
207
+ return (
208
+ <View style={[styles.overflowSlot, { left: `${(props.col / 7) * 100}%`, top: props.top }]}>
209
+ <Popover side="bottom" align="start">
210
+ <PopoverTrigger>
211
+ <FocusRingPressable accessibilityRole="button" accessibilityLabel={`${props.heading}, ${props.label}`} style={styles.overflowPress}>
212
+ <Text size="xs" color="muted" numberOfLines={1}>{props.label}</Text>
213
+ </FocusRingPressable>
214
+ </PopoverTrigger>
215
+ <PopoverContent width={260}>
216
+ <View style={styles.dayPanel}>
217
+ <FocusRingPressable accessibilityRole="button" onPress={props.onOpenDay} accessibilityLabel={`${props.heading} — ${props.openDayLabel}`}>
218
+ <Text size="sm" weight="semibold">{props.heading}</Text>
219
+ </FocusRingPressable>
220
+ {dayEvents.map((e) => (
221
+ <EventChip
222
+ key={e.id}
223
+ event={e}
224
+ variant={isBanner(e) ? "banner" : "dot"}
225
+ showTime={!isBanner(e)}
226
+ height={LANE_H}
227
+ locale={props.locale}
228
+ render={props.renderEvent}
229
+ onPress={props.onEventPress ? () => props.onEventPress?.(e.id) : undefined}
230
+ style={{ height: LANE_H }}
231
+ />
232
+ ))}
233
+ </View>
234
+ </PopoverContent>
235
+ </Popover>
236
+ </View>
237
+ );
238
+ }
239
+
205
240
  const styles = StyleSheet.create({
206
241
  root: { flex: 1, backgroundColor: colors.white },
207
- weekdayHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
242
+ // The weekday strip is a LABEL row, not a boundary space sets it off.
243
+ weekdayHeader: { flexDirection: "row", paddingTop: 2, paddingBottom: 8 },
208
244
  weekdayCell: { flex: 1, alignItems: "center" },
209
- weekRow: { flex: 1, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
210
- dateRow: { flexDirection: "row", paddingTop: 4 },
245
+ weekRow: { flex: 1, borderTopWidth: 1, borderTopColor: colors.zinc[100], minHeight: DATE_ROW_H + LANE_H },
246
+ dateRow: { flexDirection: "row", height: DATE_ROW_H, paddingTop: 4 },
211
247
  dateCell: { flex: 1, alignItems: "center" },
212
248
  dateBadge: { minWidth: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
213
- laneArea: { flex: 1, position: "relative", marginTop: 2 },
249
+ dateBadgeToday: { backgroundColor: colors.teal[600] },
250
+ dateToday: { color: colors.white },
251
+ dateIn: { color: colors.zinc[800] },
252
+ dateOut: { color: colors.zinc[400] },
253
+ laneArea: { flex: 1, position: "relative" },
254
+ colRule: { position: "absolute", top: -DATE_ROW_H, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
255
+ overflowSlot: { position: "absolute", width: `${100 / 7}%`, paddingHorizontal: 4 },
256
+ overflowPress: { paddingHorizontal: 2, height: OVERFLOW_CHIP_H, justifyContent: "center" },
257
+ dayPanel: { gap: 4, paddingVertical: 4 },
214
258
  });
@@ -0,0 +1,174 @@
1
+ import { addDays, dayDiff, startOfDay } from "./dates";
2
+ import type { CalendarEvent, Weekday } from "./types";
3
+
4
+ /**
5
+ * A repeating event, in the shape business scheduling actually uses.
6
+ *
7
+ * **This is deliberately NOT RFC 5545.** A full RRULE (`BYSETPOS`, ordinal
8
+ * `BYDAY`, `EXDATE`, the DST rules) is either a real dependency or several
9
+ * hundred lines of spec, in a calendar whose stated virtue is that it adds no
10
+ * date library to an app. What it buys over this is the long tail — "the last
11
+ * working Friday of the quarter" — and what it costs is paid by every app that
12
+ * only ever needed "every weekday at 06:00".
13
+ *
14
+ * So: a timetable, a standup, a monthly close. When something genuinely needs
15
+ * the tail, the answer is a recurrence FIELD TYPE in the platform and a parser
16
+ * behind it — not widening this.
17
+ */
18
+ export interface RepeatRule {
19
+ every: "day" | "week" | "month";
20
+ /** Every N days/weeks/months. Default 1; values below 1 are treated as 1. */
21
+ interval?: number;
22
+ /** `week` only — which weekdays. Defaults to the start date's own weekday. */
23
+ on?: Weekday[];
24
+ /** Inclusive last day the event may occur on. */
25
+ until?: Date | null;
26
+ /** Stop after this many occurrences, counting the first. */
27
+ count?: number | null;
28
+ }
29
+
30
+ /** A safety stop, so a malformed rule cannot spin. No real range asks for more. */
31
+ const MAX_OCCURRENCES = 1000;
32
+
33
+ /**
34
+ * The id an occurrence carries: the source event's id, then the day it falls on.
35
+ *
36
+ * Occurrences need distinct ids — React keys them, and the layout dedupes on
37
+ * them — but a press has to reach the ROW the series came from. Encoding the
38
+ * source in the id is what lets {@link sourceEventId} take it back off, so no
39
+ * caller has to keep a side table.
40
+ */
41
+ export function occurrenceId(sourceId: string, day: Date): string {
42
+ const m = String(day.getMonth() + 1).padStart(2, "0");
43
+ const d = String(day.getDate()).padStart(2, "0");
44
+ return `${sourceId}@${day.getFullYear()}-${m}-${d}`;
45
+ }
46
+
47
+ /** The id of the event an occurrence came from — its own id, if it is not one. */
48
+ export function sourceEventId(id: string): string {
49
+ const at = id.lastIndexOf("@");
50
+ return at === -1 ? id : id.slice(0, at);
51
+ }
52
+
53
+ /**
54
+ * Expand every repeating event in `events` into the occurrences that fall in
55
+ * [rangeStart, rangeEnd]. Events without a `repeat` pass through untouched.
56
+ *
57
+ * Bounded by the range, which is why the calendar calls it per view rather than
58
+ * asking the app to expand a year up front: navigating a month at a time over a
59
+ * daily timetable would otherwise materialise thousands of events nobody looks
60
+ * at.
61
+ */
62
+ export function expandRepeats<T>(
63
+ events: CalendarEvent<T>[],
64
+ rangeStart: Date,
65
+ rangeEnd: Date,
66
+ ): CalendarEvent<T>[] {
67
+ const out: CalendarEvent<T>[] = [];
68
+ for (const event of events) {
69
+ if (!event.repeat) {
70
+ out.push(event);
71
+ continue;
72
+ }
73
+ for (const day of occurrenceDays(event.start, event.repeat, rangeStart, rangeEnd)) {
74
+ out.push(shiftTo(event, day));
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The days a rule fires on inside a range.
82
+ *
83
+ * Walks from the series start rather than from the range, because `count` and
84
+ * `interval` are both defined relative to the FIRST occurrence — a rule that
85
+ * starts counting at whatever range the reader happens to be looking at would
86
+ * put "every other Tuesday" on a different Tuesday in March than in April.
87
+ */
88
+ export function occurrenceDays(
89
+ seriesStart: Date,
90
+ rule: RepeatRule,
91
+ rangeStart: Date,
92
+ rangeEnd: Date,
93
+ ): Date[] {
94
+ const interval = Math.max(1, Math.floor(rule.interval ?? 1));
95
+ const from = startOfDay(seriesStart);
96
+ const hardEnd = rule.until ? startOfDay(rule.until) : null;
97
+ const days: Date[] = [];
98
+ let produced = 0;
99
+
100
+ const emit = (day: Date): "stop" | "go" => {
101
+ if (hardEnd && day > hardEnd) return "stop";
102
+ produced += 1;
103
+ if (rule.count != null && produced > rule.count) return "stop";
104
+ if (day >= startOfDay(rangeStart) && day <= startOfDay(rangeEnd)) days.push(day);
105
+ // Past the range with no reason to keep counting: only `count` needs the
106
+ // walk to continue, and it has been satisfied above.
107
+ if (day > startOfDay(rangeEnd)) return "stop";
108
+ return produced >= MAX_OCCURRENCES ? "stop" : "go";
109
+ };
110
+
111
+ if (rule.every === "day") {
112
+ for (let cursor = from; ; cursor = addDays(cursor, interval)) {
113
+ if (emit(cursor) === "stop") break;
114
+ }
115
+ return days;
116
+ }
117
+
118
+ if (rule.every === "week") {
119
+ // Which weekdays, in the week's own order, so occurrences come out sorted.
120
+ const weekdays = (rule.on?.length ? [...new Set(rule.on)] : [from.getDay() as Weekday]).sort(
121
+ (a, b) => a - b,
122
+ );
123
+ // Anchor on the series start's own week so `interval` counts whole weeks.
124
+ const anchor = addDays(from, -(((from.getDay() - weekdays[0] + 7) % 7)));
125
+ for (let week = anchor; ; week = addDays(week, 7 * interval)) {
126
+ let stopped = false;
127
+ for (const wd of weekdays) {
128
+ const day = addDays(week, (wd - week.getDay() + 7) % 7);
129
+ // A weekday earlier in the anchor week than the series start is not an
130
+ // occurrence — the series has not begun yet.
131
+ if (day < from) continue;
132
+ if (emit(day) === "stop") {
133
+ stopped = true;
134
+ break;
135
+ }
136
+ }
137
+ if (stopped) break;
138
+ }
139
+ return days;
140
+ }
141
+
142
+ // month — the same day-of-month each time. A month too short SKIPS rather
143
+ // than clamping: "the 31st" does not happen in February, and sliding it to
144
+ // the 28th invents an occurrence the reader never scheduled.
145
+ const dom = from.getDate();
146
+ for (let step = 0; ; step += interval) {
147
+ const probe = new Date(from.getFullYear(), from.getMonth() + step, 1);
148
+ const lastDay = new Date(probe.getFullYear(), probe.getMonth() + 1, 0).getDate();
149
+ if (dom > lastDay) {
150
+ // Not an occurrence, but the walk must still know when to give up.
151
+ if (probe > startOfDay(rangeEnd)) break;
152
+ if (hardEnd && probe > hardEnd) break;
153
+ if (step / interval >= MAX_OCCURRENCES) break;
154
+ continue;
155
+ }
156
+ const day = new Date(probe.getFullYear(), probe.getMonth(), dom);
157
+ if (emit(day) === "stop") break;
158
+ }
159
+ return days;
160
+ }
161
+
162
+ /** One occurrence: the same event, moved to `day`, keeping its duration. */
163
+ function shiftTo<T>(event: CalendarEvent<T>, day: Date): CalendarEvent<T> {
164
+ const offset = dayDiff(startOfDay(event.start), day);
165
+ if (offset === 0) return { ...event, id: occurrenceId(event.id, day), repeat: undefined };
166
+ const start = addDays(event.start, offset);
167
+ start.setHours(event.start.getHours(), event.start.getMinutes(), 0, 0);
168
+ let end: Date | null = null;
169
+ if (event.end) {
170
+ end = addDays(event.end, offset);
171
+ end.setHours(event.end.getHours(), event.end.getMinutes(), 0, 0);
172
+ }
173
+ return { ...event, id: occurrenceId(event.id, day), start, end, repeat: undefined };
174
+ }