@super-calendar/core 2.3.1 → 2.4.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.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Locale } from "date-fns";
2
2
  import { Dispatch, SetStateAction } from "react";
3
-
4
3
  //#region src/types.d.ts
5
4
  /** The view the calendar renders: a day-column grid (`day`, `3days`, `week`, `custom`), the `month` grid, or the `schedule` list. */
6
5
  type CalendarMode = "day" | "3days" | "week" | "custom" | "month" | "schedule";
@@ -300,10 +299,7 @@ declare const getViewDays: (mode: CalendarMode, date: Date, weekStartsOn: WeekSt
300
299
  * `weekStartsOn`. `showSixWeeks` always returns six rows (42 days) for a
301
300
  * fixed-height grid; `isRTL` reverses each week's day order.
302
301
  */
303
- declare const buildMonthWeeks: (month: Date, weekStartsOn: WeekStartsOn, {
304
- showSixWeeks,
305
- isRTL
306
- }?: {
302
+ declare const buildMonthWeeks: (month: Date, weekStartsOn: WeekStartsOn, { showSixWeeks, isRTL }?: {
307
303
  showSixWeeks?: boolean;
308
304
  isRTL?: boolean;
309
305
  }) => Date[][];
@@ -328,10 +324,11 @@ declare function shiftMinutes(date: Date, minutes: number): Date;
328
324
  /**
329
325
  * Resolve a committed drag into the event's new bounds: `start` shifts by
330
326
  * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
331
- * to both; a resize passes 0 for the start). Returns `null` when the change
332
- * would collapse the event below one `snapMinutes` step, so the gesture leaves
333
- * the event untouched rather than committing a degenerate duration. Pure, so the
334
- * commit path is unit-testable without a running gesture.
327
+ * to both; a resize passes 0 for the start). Returns `null` only when the change
328
+ * would *shrink* the event below one `snapMinutes` step, so a resize can't commit
329
+ * a degenerate duration; a pure move (both deltas equal) keeps its duration and is
330
+ * never rejected, even for an already sub-step event. Pure, so the commit path is
331
+ * unit-testable without a running gesture.
335
332
  */
336
333
  declare function resolveDraggedBounds(start: Date, end: Date, deltaStartMinutes: number, deltaEndMinutes: number, snapMinutes: number): {
337
334
  start: Date;
@@ -367,7 +364,8 @@ declare function eventAccessibilityLabel(args: {
367
364
  isAllDay: boolean;
368
365
  start: Date;
369
366
  end: Date;
370
- ampm: boolean; /** Spoken text for an all-day event. Default "all day". */
367
+ ampm: boolean;
368
+ /** Spoken text for an all-day event. Default "all day". */
371
369
  allDayLabel?: string;
372
370
  }): string;
373
371
  /**
@@ -408,7 +406,8 @@ declare function eventTimeLabel(args: {
408
406
  start: Date;
409
407
  end: Date;
410
408
  ampm: boolean;
411
- showTime: boolean; /** Text for an all-day event in the schedule. Default "All day". */
409
+ showTime: boolean;
410
+ /** Text for an all-day event in the schedule. Default "All day". */
412
411
  allDayLabel?: string;
413
412
  }): string | null;
414
413
  /**
@@ -434,7 +433,8 @@ type EventChipLayout = {
434
433
  * Max whole title lines that fit in the box. `0` means "no clamp" (the box
435
434
  * height is unknown, e.g. the schedule), so the title may wrap freely.
436
435
  */
437
- titleMaxLines: number; /** Whether the secondary time line still has room below the title. */
436
+ titleMaxLines: number;
437
+ /** Whether the secondary time line still has room below the title. */
438
438
  showTime: boolean;
439
439
  };
440
440
  /**
@@ -460,7 +460,9 @@ declare function eventChipLayout(args: {
460
460
  }): EventChipLayout;
461
461
  /** How many month-cell chips fit in the available height. */
462
462
  type MonthEventCapacity = {
463
- /** Count when every event fits, with no overflow label. */full: number; /** Count that leaves room for the "+N more" label. */
463
+ /** Count when every event fits, with no overflow label. */
464
+ full: number;
465
+ /** Count that leaves room for the "+N more" label. */
464
466
  withMore: number;
465
467
  };
466
468
  /**
@@ -519,11 +521,17 @@ declare function toICalendar(events: ICalEvent[], options?: ToICalendarOptions):
519
521
  //#region src/utils/layout.d.ts
520
522
  /** An event placed on a single day's time grid by {@link layoutDayEvents}, with its vertical span and overlap column. */
521
523
  type PositionedEvent<T> = {
522
- /** The source event for this segment. */event: CalendarEvent<T>; /** Hours from midnight to the event's segment start on this day (fractional). */
523
- startHours: number; /** Segment duration in hours on this day (clamped to a small minimum). */
524
- durationHours: number; /** Zero-based column index within its overlap cluster. */
525
- column: number; /** Total columns in this event's overlap cluster. */
526
- columns: number; /** True when the segment is clipped because the event continues before/after this day. */
524
+ /** The source event for this segment. */
525
+ event: CalendarEvent<T>;
526
+ /** Hours from midnight to the event's segment start on this day (fractional). */
527
+ startHours: number;
528
+ /** Segment duration in hours on this day (clamped to a small minimum). */
529
+ durationHours: number;
530
+ /** Zero-based column index within its overlap cluster. */
531
+ column: number;
532
+ /** Total columns in this event's overlap cluster. */
533
+ columns: number;
534
+ /** True when the segment is clipped because the event continues before/after this day. */
527
535
  continuesBefore: boolean;
528
536
  continuesAfter: boolean;
529
537
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Dispatch, SetStateAction } from "react";
2
2
  import { Locale } from "date-fns";
3
-
4
3
  //#region src/types.d.ts
5
4
  /** The view the calendar renders: a day-column grid (`day`, `3days`, `week`, `custom`), the `month` grid, or the `schedule` list. */
6
5
  type CalendarMode = "day" | "3days" | "week" | "custom" | "month" | "schedule";
@@ -300,10 +299,7 @@ declare const getViewDays: (mode: CalendarMode, date: Date, weekStartsOn: WeekSt
300
299
  * `weekStartsOn`. `showSixWeeks` always returns six rows (42 days) for a
301
300
  * fixed-height grid; `isRTL` reverses each week's day order.
302
301
  */
303
- declare const buildMonthWeeks: (month: Date, weekStartsOn: WeekStartsOn, {
304
- showSixWeeks,
305
- isRTL
306
- }?: {
302
+ declare const buildMonthWeeks: (month: Date, weekStartsOn: WeekStartsOn, { showSixWeeks, isRTL }?: {
307
303
  showSixWeeks?: boolean;
308
304
  isRTL?: boolean;
309
305
  }) => Date[][];
@@ -328,10 +324,11 @@ declare function shiftMinutes(date: Date, minutes: number): Date;
328
324
  /**
329
325
  * Resolve a committed drag into the event's new bounds: `start` shifts by
330
326
  * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
331
- * to both; a resize passes 0 for the start). Returns `null` when the change
332
- * would collapse the event below one `snapMinutes` step, so the gesture leaves
333
- * the event untouched rather than committing a degenerate duration. Pure, so the
334
- * commit path is unit-testable without a running gesture.
327
+ * to both; a resize passes 0 for the start). Returns `null` only when the change
328
+ * would *shrink* the event below one `snapMinutes` step, so a resize can't commit
329
+ * a degenerate duration; a pure move (both deltas equal) keeps its duration and is
330
+ * never rejected, even for an already sub-step event. Pure, so the commit path is
331
+ * unit-testable without a running gesture.
335
332
  */
336
333
  declare function resolveDraggedBounds(start: Date, end: Date, deltaStartMinutes: number, deltaEndMinutes: number, snapMinutes: number): {
337
334
  start: Date;
@@ -367,7 +364,8 @@ declare function eventAccessibilityLabel(args: {
367
364
  isAllDay: boolean;
368
365
  start: Date;
369
366
  end: Date;
370
- ampm: boolean; /** Spoken text for an all-day event. Default "all day". */
367
+ ampm: boolean;
368
+ /** Spoken text for an all-day event. Default "all day". */
371
369
  allDayLabel?: string;
372
370
  }): string;
373
371
  /**
@@ -408,7 +406,8 @@ declare function eventTimeLabel(args: {
408
406
  start: Date;
409
407
  end: Date;
410
408
  ampm: boolean;
411
- showTime: boolean; /** Text for an all-day event in the schedule. Default "All day". */
409
+ showTime: boolean;
410
+ /** Text for an all-day event in the schedule. Default "All day". */
412
411
  allDayLabel?: string;
413
412
  }): string | null;
414
413
  /**
@@ -434,7 +433,8 @@ type EventChipLayout = {
434
433
  * Max whole title lines that fit in the box. `0` means "no clamp" (the box
435
434
  * height is unknown, e.g. the schedule), so the title may wrap freely.
436
435
  */
437
- titleMaxLines: number; /** Whether the secondary time line still has room below the title. */
436
+ titleMaxLines: number;
437
+ /** Whether the secondary time line still has room below the title. */
438
438
  showTime: boolean;
439
439
  };
440
440
  /**
@@ -460,7 +460,9 @@ declare function eventChipLayout(args: {
460
460
  }): EventChipLayout;
461
461
  /** How many month-cell chips fit in the available height. */
462
462
  type MonthEventCapacity = {
463
- /** Count when every event fits, with no overflow label. */full: number; /** Count that leaves room for the "+N more" label. */
463
+ /** Count when every event fits, with no overflow label. */
464
+ full: number;
465
+ /** Count that leaves room for the "+N more" label. */
464
466
  withMore: number;
465
467
  };
466
468
  /**
@@ -519,11 +521,17 @@ declare function toICalendar(events: ICalEvent[], options?: ToICalendarOptions):
519
521
  //#region src/utils/layout.d.ts
520
522
  /** An event placed on a single day's time grid by {@link layoutDayEvents}, with its vertical span and overlap column. */
521
523
  type PositionedEvent<T> = {
522
- /** The source event for this segment. */event: CalendarEvent<T>; /** Hours from midnight to the event's segment start on this day (fractional). */
523
- startHours: number; /** Segment duration in hours on this day (clamped to a small minimum). */
524
- durationHours: number; /** Zero-based column index within its overlap cluster. */
525
- column: number; /** Total columns in this event's overlap cluster. */
526
- columns: number; /** True when the segment is clipped because the event continues before/after this day. */
524
+ /** The source event for this segment. */
525
+ event: CalendarEvent<T>;
526
+ /** Hours from midnight to the event's segment start on this day (fractional). */
527
+ startHours: number;
528
+ /** Segment duration in hours on this day (clamped to a small minimum). */
529
+ durationHours: number;
530
+ /** Zero-based column index within its overlap cluster. */
531
+ column: number;
532
+ /** Total columns in this event's overlap cluster. */
533
+ columns: number;
534
+ /** True when the segment is clipped because the event continues before/after this day. */
527
535
  continuesBefore: boolean;
528
536
  continuesAfter: boolean;
529
537
  };
package/dist/index.js CHANGED
@@ -278,15 +278,18 @@ function shiftMinutes(date, minutes) {
278
278
  /**
279
279
  * Resolve a committed drag into the event's new bounds: `start` shifts by
280
280
  * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
281
- * to both; a resize passes 0 for the start). Returns `null` when the change
282
- * would collapse the event below one `snapMinutes` step, so the gesture leaves
283
- * the event untouched rather than committing a degenerate duration. Pure, so the
284
- * commit path is unit-testable without a running gesture.
281
+ * to both; a resize passes 0 for the start). Returns `null` only when the change
282
+ * would *shrink* the event below one `snapMinutes` step, so a resize can't commit
283
+ * a degenerate duration; a pure move (both deltas equal) keeps its duration and is
284
+ * never rejected, even for an already sub-step event. Pure, so the commit path is
285
+ * unit-testable without a running gesture.
285
286
  */
286
287
  function resolveDraggedBounds(start, end, deltaStartMinutes, deltaEndMinutes, snapMinutes) {
287
288
  const nextStart = shiftMinutes(start, deltaStartMinutes);
288
289
  const nextEnd = shiftMinutes(end, deltaEndMinutes);
289
- if (nextEnd.getTime() - nextStart.getTime() < snapMinutes * 6e4) return null;
290
+ const oldDuration = end.getTime() - start.getTime();
291
+ const newDuration = nextEnd.getTime() - nextStart.getTime();
292
+ if (newDuration < snapMinutes * 6e4 && newDuration < oldDuration) return null;
290
293
  return {
291
294
  start: nextStart,
292
295
  end: nextEnd
@@ -1023,6 +1026,12 @@ const STEP = {
1023
1026
  yearly: date_fns.addYears
1024
1027
  };
1025
1028
  const MAX_OCCURRENCES = 5e3;
1029
+ const APPROX_STEP_MS = {
1030
+ daily: 864e5,
1031
+ weekly: 6048e5,
1032
+ monthly: 2629746e3,
1033
+ yearly: 31556952e3
1034
+ };
1026
1035
  function withTimeOf(date, source) {
1027
1036
  const next = new Date(date);
1028
1037
  next.setHours(source.getHours(), source.getMinutes(), source.getSeconds(), source.getMilliseconds());
@@ -1038,10 +1047,21 @@ function nthWeekdayOfMonth(year, month, week, weekday) {
1038
1047
  const date = new Date(year, month, day);
1039
1048
  return date.getMonth() === month ? date : null;
1040
1049
  }
1041
- function* occurrenceStarts(start, rule, rangeEnd) {
1050
+ function* occurrenceStarts(start, rule, earliestStart, rangeEnd) {
1042
1051
  const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
1043
1052
  let produced = 0;
1044
- const within = (date) => date.getTime() <= rangeEnd.getTime() && (rule.until == null || date.getTime() <= rule.until.getTime()) && (rule.count == null || produced < rule.count) && produced < MAX_OCCURRENCES;
1053
+ let emitted = 0;
1054
+ const consider = (date) => {
1055
+ if (date.getTime() < start.getTime()) return "skip";
1056
+ if (date.getTime() > rangeEnd.getTime()) return "stop";
1057
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return "stop";
1058
+ if (rule.count != null && produced >= rule.count) return "stop";
1059
+ produced += 1;
1060
+ if (date.getTime() < earliestStart.getTime()) return "skip";
1061
+ if (emitted >= MAX_OCCURRENCES) return "stop";
1062
+ emitted += 1;
1063
+ return "emit";
1064
+ };
1045
1065
  if (rule.freq === "weekly" && rule.weekdays?.length) {
1046
1066
  const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
1047
1067
  let weekStart = (0, date_fns.startOfWeek)(start, { weekStartsOn: 0 });
@@ -1049,9 +1069,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1049
1069
  let advanced = false;
1050
1070
  for (const weekday of weekdays) {
1051
1071
  const date = withTimeOf((0, date_fns.addDays)(weekStart, weekday), start);
1052
- if (date.getTime() < start.getTime()) continue;
1053
- if (!within(date)) return;
1054
- produced += 1;
1072
+ const verdict = consider(date);
1073
+ if (verdict === "stop") return;
1074
+ if (verdict === "skip") continue;
1055
1075
  advanced = true;
1056
1076
  yield date;
1057
1077
  }
@@ -1068,9 +1088,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1068
1088
  const days = [...new Set(rule.monthDays.map((d) => d < 0 ? daysInMonth + d + 1 : d))].filter((d) => d >= 1 && d <= daysInMonth).sort((a, b) => a - b);
1069
1089
  for (const d of days) {
1070
1090
  const date = withTimeOf(new Date(year, month, d), start);
1071
- if (date.getTime() < start.getTime()) continue;
1072
- if (!within(date)) return;
1073
- produced += 1;
1091
+ const verdict = consider(date);
1092
+ if (verdict === "stop") return;
1093
+ if (verdict === "skip") continue;
1074
1094
  yield date;
1075
1095
  }
1076
1096
  month += interval;
@@ -1088,11 +1108,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1088
1108
  const day = nthWeekdayOfMonth(year, month, week, weekday);
1089
1109
  if (day) {
1090
1110
  const date = withTimeOf(day, start);
1091
- if (date.getTime() >= start.getTime()) {
1092
- if (!within(date)) return;
1093
- produced += 1;
1094
- yield date;
1095
- }
1111
+ const verdict = consider(date);
1112
+ if (verdict === "stop") return;
1113
+ if (verdict === "emit") yield date;
1096
1114
  }
1097
1115
  month += stepMonths;
1098
1116
  year += Math.floor(month / 12);
@@ -1109,20 +1127,32 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1109
1127
  const month = m - 1;
1110
1128
  if (day > new Date(year, month + 1, 0).getDate()) continue;
1111
1129
  const date = withTimeOf(new Date(year, month, day), start);
1112
- if (date.getTime() < start.getTime()) continue;
1113
- if (!within(date)) return;
1114
- produced += 1;
1130
+ const verdict = consider(date);
1131
+ if (verdict === "stop") return;
1132
+ if (verdict === "skip") continue;
1115
1133
  yield date;
1116
1134
  }
1117
1135
  year += interval;
1118
1136
  if (new Date(year, 0, 1).getTime() > rangeEnd.getTime()) return;
1119
1137
  }
1120
1138
  }
1121
- let cursor = start;
1122
- while (within(cursor)) {
1123
- produced += 1;
1124
- yield cursor;
1125
- cursor = STEP[rule.freq](cursor, interval);
1139
+ const occAt = (n) => STEP[rule.freq](start, n * interval);
1140
+ const lowerBound = earliestStart.getTime();
1141
+ let n = 0;
1142
+ if (occAt(0).getTime() < lowerBound) {
1143
+ n = Math.max(0, Math.floor((lowerBound - start.getTime()) / (APPROX_STEP_MS[rule.freq] * interval)));
1144
+ while (n > 0 && occAt(n - 1).getTime() >= lowerBound) n -= 1;
1145
+ while (occAt(n).getTime() < lowerBound) n += 1;
1146
+ }
1147
+ while (true) {
1148
+ if (rule.count != null && n >= rule.count) return;
1149
+ if (emitted >= MAX_OCCURRENCES) return;
1150
+ const date = occAt(n);
1151
+ if (date.getTime() > rangeEnd.getTime()) return;
1152
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return;
1153
+ emitted += 1;
1154
+ n += 1;
1155
+ yield date;
1126
1156
  }
1127
1157
  }
1128
1158
  function instanceAt(event, start, durationMs) {
@@ -1151,7 +1181,8 @@ function expandRecurringEvents(events, rangeStart, rangeEnd) {
1151
1181
  const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
1152
1182
  const excluded = new Set((event.recurrence.exdates ?? []).map(dayKey));
1153
1183
  const starts = /* @__PURE__ */ new Map();
1154
- for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) starts.set(start.getTime(), start);
1184
+ const earliestStart = new Date(rangeStart.getTime() - durationMs);
1185
+ for (const start of occurrenceStarts(event.start, event.recurrence, earliestStart, rangeEnd)) starts.set(start.getTime(), start);
1155
1186
  for (const rdate of event.recurrence.rdates ?? []) if (rdate.getTime() <= rangeEnd.getTime()) starts.set(rdate.getTime(), rdate);
1156
1187
  const ordered = [...starts.values()].sort((a, b) => a.getTime() - b.getTime());
1157
1188
  for (const start of ordered) {
package/dist/index.mjs CHANGED
@@ -277,15 +277,18 @@ function shiftMinutes(date, minutes) {
277
277
  /**
278
278
  * Resolve a committed drag into the event's new bounds: `start` shifts by
279
279
  * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
280
- * to both; a resize passes 0 for the start). Returns `null` when the change
281
- * would collapse the event below one `snapMinutes` step, so the gesture leaves
282
- * the event untouched rather than committing a degenerate duration. Pure, so the
283
- * commit path is unit-testable without a running gesture.
280
+ * to both; a resize passes 0 for the start). Returns `null` only when the change
281
+ * would *shrink* the event below one `snapMinutes` step, so a resize can't commit
282
+ * a degenerate duration; a pure move (both deltas equal) keeps its duration and is
283
+ * never rejected, even for an already sub-step event. Pure, so the commit path is
284
+ * unit-testable without a running gesture.
284
285
  */
285
286
  function resolveDraggedBounds(start, end, deltaStartMinutes, deltaEndMinutes, snapMinutes) {
286
287
  const nextStart = shiftMinutes(start, deltaStartMinutes);
287
288
  const nextEnd = shiftMinutes(end, deltaEndMinutes);
288
- if (nextEnd.getTime() - nextStart.getTime() < snapMinutes * 6e4) return null;
289
+ const oldDuration = end.getTime() - start.getTime();
290
+ const newDuration = nextEnd.getTime() - nextStart.getTime();
291
+ if (newDuration < snapMinutes * 6e4 && newDuration < oldDuration) return null;
289
292
  return {
290
293
  start: nextStart,
291
294
  end: nextEnd
@@ -1022,6 +1025,12 @@ const STEP = {
1022
1025
  yearly: addYears
1023
1026
  };
1024
1027
  const MAX_OCCURRENCES = 5e3;
1028
+ const APPROX_STEP_MS = {
1029
+ daily: 864e5,
1030
+ weekly: 6048e5,
1031
+ monthly: 2629746e3,
1032
+ yearly: 31556952e3
1033
+ };
1025
1034
  function withTimeOf(date, source) {
1026
1035
  const next = new Date(date);
1027
1036
  next.setHours(source.getHours(), source.getMinutes(), source.getSeconds(), source.getMilliseconds());
@@ -1037,10 +1046,21 @@ function nthWeekdayOfMonth(year, month, week, weekday) {
1037
1046
  const date = new Date(year, month, day);
1038
1047
  return date.getMonth() === month ? date : null;
1039
1048
  }
1040
- function* occurrenceStarts(start, rule, rangeEnd) {
1049
+ function* occurrenceStarts(start, rule, earliestStart, rangeEnd) {
1041
1050
  const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
1042
1051
  let produced = 0;
1043
- const within = (date) => date.getTime() <= rangeEnd.getTime() && (rule.until == null || date.getTime() <= rule.until.getTime()) && (rule.count == null || produced < rule.count) && produced < MAX_OCCURRENCES;
1052
+ let emitted = 0;
1053
+ const consider = (date) => {
1054
+ if (date.getTime() < start.getTime()) return "skip";
1055
+ if (date.getTime() > rangeEnd.getTime()) return "stop";
1056
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return "stop";
1057
+ if (rule.count != null && produced >= rule.count) return "stop";
1058
+ produced += 1;
1059
+ if (date.getTime() < earliestStart.getTime()) return "skip";
1060
+ if (emitted >= MAX_OCCURRENCES) return "stop";
1061
+ emitted += 1;
1062
+ return "emit";
1063
+ };
1044
1064
  if (rule.freq === "weekly" && rule.weekdays?.length) {
1045
1065
  const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
1046
1066
  let weekStart = startOfWeek(start, { weekStartsOn: 0 });
@@ -1048,9 +1068,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1048
1068
  let advanced = false;
1049
1069
  for (const weekday of weekdays) {
1050
1070
  const date = withTimeOf(addDays(weekStart, weekday), start);
1051
- if (date.getTime() < start.getTime()) continue;
1052
- if (!within(date)) return;
1053
- produced += 1;
1071
+ const verdict = consider(date);
1072
+ if (verdict === "stop") return;
1073
+ if (verdict === "skip") continue;
1054
1074
  advanced = true;
1055
1075
  yield date;
1056
1076
  }
@@ -1067,9 +1087,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1067
1087
  const days = [...new Set(rule.monthDays.map((d) => d < 0 ? daysInMonth + d + 1 : d))].filter((d) => d >= 1 && d <= daysInMonth).sort((a, b) => a - b);
1068
1088
  for (const d of days) {
1069
1089
  const date = withTimeOf(new Date(year, month, d), start);
1070
- if (date.getTime() < start.getTime()) continue;
1071
- if (!within(date)) return;
1072
- produced += 1;
1090
+ const verdict = consider(date);
1091
+ if (verdict === "stop") return;
1092
+ if (verdict === "skip") continue;
1073
1093
  yield date;
1074
1094
  }
1075
1095
  month += interval;
@@ -1087,11 +1107,9 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1087
1107
  const day = nthWeekdayOfMonth(year, month, week, weekday);
1088
1108
  if (day) {
1089
1109
  const date = withTimeOf(day, start);
1090
- if (date.getTime() >= start.getTime()) {
1091
- if (!within(date)) return;
1092
- produced += 1;
1093
- yield date;
1094
- }
1110
+ const verdict = consider(date);
1111
+ if (verdict === "stop") return;
1112
+ if (verdict === "emit") yield date;
1095
1113
  }
1096
1114
  month += stepMonths;
1097
1115
  year += Math.floor(month / 12);
@@ -1108,20 +1126,32 @@ function* occurrenceStarts(start, rule, rangeEnd) {
1108
1126
  const month = m - 1;
1109
1127
  if (day > new Date(year, month + 1, 0).getDate()) continue;
1110
1128
  const date = withTimeOf(new Date(year, month, day), start);
1111
- if (date.getTime() < start.getTime()) continue;
1112
- if (!within(date)) return;
1113
- produced += 1;
1129
+ const verdict = consider(date);
1130
+ if (verdict === "stop") return;
1131
+ if (verdict === "skip") continue;
1114
1132
  yield date;
1115
1133
  }
1116
1134
  year += interval;
1117
1135
  if (new Date(year, 0, 1).getTime() > rangeEnd.getTime()) return;
1118
1136
  }
1119
1137
  }
1120
- let cursor = start;
1121
- while (within(cursor)) {
1122
- produced += 1;
1123
- yield cursor;
1124
- cursor = STEP[rule.freq](cursor, interval);
1138
+ const occAt = (n) => STEP[rule.freq](start, n * interval);
1139
+ const lowerBound = earliestStart.getTime();
1140
+ let n = 0;
1141
+ if (occAt(0).getTime() < lowerBound) {
1142
+ n = Math.max(0, Math.floor((lowerBound - start.getTime()) / (APPROX_STEP_MS[rule.freq] * interval)));
1143
+ while (n > 0 && occAt(n - 1).getTime() >= lowerBound) n -= 1;
1144
+ while (occAt(n).getTime() < lowerBound) n += 1;
1145
+ }
1146
+ while (true) {
1147
+ if (rule.count != null && n >= rule.count) return;
1148
+ if (emitted >= MAX_OCCURRENCES) return;
1149
+ const date = occAt(n);
1150
+ if (date.getTime() > rangeEnd.getTime()) return;
1151
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return;
1152
+ emitted += 1;
1153
+ n += 1;
1154
+ yield date;
1125
1155
  }
1126
1156
  }
1127
1157
  function instanceAt(event, start, durationMs) {
@@ -1150,7 +1180,8 @@ function expandRecurringEvents(events, rangeStart, rangeEnd) {
1150
1180
  const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
1151
1181
  const excluded = new Set((event.recurrence.exdates ?? []).map(dayKey));
1152
1182
  const starts = /* @__PURE__ */ new Map();
1153
- for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) starts.set(start.getTime(), start);
1183
+ const earliestStart = new Date(rangeStart.getTime() - durationMs);
1184
+ for (const start of occurrenceStarts(event.start, event.recurrence, earliestStart, rangeEnd)) starts.set(start.getTime(), start);
1154
1185
  for (const rdate of event.recurrence.rdates ?? []) if (rdate.getTime() <= rangeEnd.getTime()) starts.set(rdate.getTime(), rdate);
1155
1186
  const ordered = [...starts.values()].sort((a, b) => a.getTime() - b.getTime());
1156
1187
  for (const start of ordered) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-calendar/core",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Render-agnostic core for super-calendar: date math, selection model, event layout, the month-grid builder, headless hooks, and neutral theme tokens. No renderer.",
5
5
  "keywords": [
6
6
  "calendar",
package/src/utils/drag.ts CHANGED
@@ -24,10 +24,11 @@ export function shiftMinutes(date: Date, minutes: number): Date {
24
24
  /**
25
25
  * Resolve a committed drag into the event's new bounds: `start` shifts by
26
26
  * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
27
- * to both; a resize passes 0 for the start). Returns `null` when the change
28
- * would collapse the event below one `snapMinutes` step, so the gesture leaves
29
- * the event untouched rather than committing a degenerate duration. Pure, so the
30
- * commit path is unit-testable without a running gesture.
27
+ * to both; a resize passes 0 for the start). Returns `null` only when the change
28
+ * would *shrink* the event below one `snapMinutes` step, so a resize can't commit
29
+ * a degenerate duration; a pure move (both deltas equal) keeps its duration and is
30
+ * never rejected, even for an already sub-step event. Pure, so the commit path is
31
+ * unit-testable without a running gesture.
31
32
  */
32
33
  export function resolveDraggedBounds(
33
34
  start: Date,
@@ -38,7 +39,10 @@ export function resolveDraggedBounds(
38
39
  ): { start: Date; end: Date } | null {
39
40
  const nextStart = shiftMinutes(start, deltaStartMinutes);
40
41
  const nextEnd = shiftMinutes(end, deltaEndMinutes);
41
- if (nextEnd.getTime() - nextStart.getTime() < snapMinutes * 60_000) return null;
42
+ const oldDuration = end.getTime() - start.getTime();
43
+ const newDuration = nextEnd.getTime() - nextStart.getTime();
44
+ // Reject only a shrink past one step — never a move, which preserves duration.
45
+ if (newDuration < snapMinutes * 60_000 && newDuration < oldDuration) return null;
42
46
  return { start: nextStart, end: nextEnd };
43
47
  }
44
48
 
@@ -8,10 +8,20 @@ const STEP: Record<RecurrenceFrequency, (date: Date, amount: number) => Date> =
8
8
  yearly: addYears,
9
9
  };
10
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.
11
+ // Runaway guard on in-range occurrences. Generous enough for realistic windows
12
+ // (e.g. ~13 years of a daily event); set `count`/`until`, or query a tighter
13
+ // range, for anything larger.
13
14
  const MAX_OCCURRENCES = 5000;
14
15
 
16
+ // Average length of each frequency's step, used only to estimate how far to
17
+ // fast-forward before iterating; the estimate is then corrected exactly.
18
+ const APPROX_STEP_MS: Record<RecurrenceFrequency, number> = {
19
+ daily: 864e5,
20
+ weekly: 6048e5,
21
+ monthly: 2629746e3,
22
+ yearly: 31556952e3,
23
+ };
24
+
15
25
  // Copy `source`'s time of day onto `date`.
16
26
  function withTimeOf(date: Date, source: Date): Date {
17
27
  const next = new Date(date);
@@ -44,15 +54,32 @@ function nthWeekdayOfMonth(
44
54
  }
45
55
 
46
56
  // Occurrence start dates from `event.start` forward, in chronological order, up
47
- // to `rangeEnd` (and the rule's own `count`/`until`).
48
- function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): Generator<Date> {
57
+ // to `rangeEnd` (and the rule's own `count`/`until`). `earliestStart` is the
58
+ // earliest occurrence-start that can still overlap the query window
59
+ // (`rangeStart - duration`); the plain fallback rule fast-forwards to it.
60
+ function* occurrenceStarts(
61
+ start: Date,
62
+ rule: RecurrenceRule,
63
+ earliestStart: Date,
64
+ rangeEnd: Date,
65
+ ): Generator<Date> {
49
66
  const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
67
+ // `produced` counts occurrences from the origin, honouring `count`; `emitted`
68
+ // counts only in-window yields, so the runaway guard isn't spent on the
69
+ // throwaway occurrences that precede a far-future query window.
50
70
  let produced = 0;
51
- const within = (date: Date) =>
52
- date.getTime() <= rangeEnd.getTime() &&
53
- (rule.until == null || date.getTime() <= rule.until.getTime()) &&
54
- (rule.count == null || produced < rule.count) &&
55
- produced < MAX_OCCURRENCES;
71
+ let emitted = 0;
72
+ const consider = (date: Date): "skip" | "stop" | "emit" => {
73
+ if (date.getTime() < start.getTime()) return "skip"; // before the first occurrence
74
+ if (date.getTime() > rangeEnd.getTime()) return "stop";
75
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return "stop";
76
+ if (rule.count != null && produced >= rule.count) return "stop";
77
+ produced += 1;
78
+ if (date.getTime() < earliestStart.getTime()) return "skip"; // before the window
79
+ if (emitted >= MAX_OCCURRENCES) return "stop";
80
+ emitted += 1;
81
+ return "emit";
82
+ };
56
83
 
57
84
  if (rule.freq === "weekly" && rule.weekdays?.length) {
58
85
  const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
@@ -61,9 +88,9 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
61
88
  let advanced = false;
62
89
  for (const weekday of weekdays) {
63
90
  const date = withTimeOf(addDays(weekStart, weekday), start);
64
- if (date.getTime() < start.getTime()) continue; // before the first occurrence
65
- if (!within(date)) return;
66
- produced += 1;
91
+ const verdict = consider(date);
92
+ if (verdict === "stop") return;
93
+ if (verdict === "skip") continue;
67
94
  advanced = true;
68
95
  yield date;
69
96
  }
@@ -86,9 +113,9 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
86
113
  .sort((a, b) => a - b);
87
114
  for (const d of days) {
88
115
  const date = withTimeOf(new Date(year, month, d), start);
89
- if (date.getTime() < start.getTime()) continue; // before the first occurrence
90
- if (!within(date)) return;
91
- produced += 1;
116
+ const verdict = consider(date);
117
+ if (verdict === "stop") return;
118
+ if (verdict === "skip") continue;
92
119
  yield date;
93
120
  }
94
121
  month += interval;
@@ -108,11 +135,9 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
108
135
  const day = nthWeekdayOfMonth(year, month, week, weekday);
109
136
  if (day) {
110
137
  const date = withTimeOf(day, start);
111
- if (date.getTime() >= start.getTime()) {
112
- if (!within(date)) return;
113
- produced += 1;
114
- yield date;
115
- }
138
+ const verdict = consider(date);
139
+ if (verdict === "stop") return;
140
+ if (verdict === "emit") yield date;
116
141
  }
117
142
  month += stepMonths;
118
143
  year += Math.floor(month / 12);
@@ -132,9 +157,9 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
132
157
  // Skip a year whose listed month lacks the start's day (e.g. Feb 29).
133
158
  if (day > new Date(year, month + 1, 0).getDate()) continue;
134
159
  const date = withTimeOf(new Date(year, month, day), start);
135
- if (date.getTime() < start.getTime()) continue; // before the first occurrence
136
- if (!within(date)) return;
137
- produced += 1;
160
+ const verdict = consider(date);
161
+ if (verdict === "stop") return;
162
+ if (verdict === "skip") continue;
138
163
  yield date;
139
164
  }
140
165
  year += interval;
@@ -142,11 +167,32 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
142
167
  }
143
168
  }
144
169
 
145
- let cursor = start;
146
- while (within(cursor)) {
147
- produced += 1;
148
- yield cursor;
149
- cursor = STEP[rule.freq](cursor, interval);
170
+ // Fallback: plain daily/weekly/monthly/yearly stepping. Each occurrence is
171
+ // computed from the original `start` (never the previous occurrence), so a
172
+ // month-end or Feb-29 start doesn't drift as date-fns clamps shorter months.
173
+ const occAt = (n: number) => STEP[rule.freq](start, n * interval);
174
+ const lowerBound = earliestStart.getTime();
175
+ // Fast-forward to the first occurrence that can overlap the window so a
176
+ // far-future query doesn't iterate (and exhaust the guard) from `start`.
177
+ let n = 0;
178
+ if (occAt(0).getTime() < lowerBound) {
179
+ n = Math.max(
180
+ 0,
181
+ Math.floor((lowerBound - start.getTime()) / (APPROX_STEP_MS[rule.freq] * interval)),
182
+ );
183
+ // Correct the estimate exactly for calendar/DST wobble.
184
+ while (n > 0 && occAt(n - 1).getTime() >= lowerBound) n -= 1;
185
+ while (occAt(n).getTime() < lowerBound) n += 1;
186
+ }
187
+ while (true) {
188
+ if (rule.count != null && n >= rule.count) return;
189
+ if (emitted >= MAX_OCCURRENCES) return;
190
+ const date = occAt(n);
191
+ if (date.getTime() > rangeEnd.getTime()) return;
192
+ if (rule.until != null && date.getTime() > rule.until.getTime()) return;
193
+ emitted += 1;
194
+ n += 1;
195
+ yield date;
150
196
  }
151
197
  }
152
198
 
@@ -181,7 +227,9 @@ export function expandRecurringEvents<T>(
181
227
  // Union the rule's occurrences with any explicit RDATE additions, keyed by
182
228
  // exact start time so a date the rule already produces isn't duplicated.
183
229
  const starts = new Map<number, Date>();
184
- for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
230
+ // Occurrences starting up to one duration before the range can still overlap it.
231
+ const earliestStart = new Date(rangeStart.getTime() - durationMs);
232
+ for (const start of occurrenceStarts(event.start, event.recurrence, earliestStart, rangeEnd)) {
185
233
  starts.set(start.getTime(), start);
186
234
  }
187
235
  for (const rdate of event.recurrence.rdates ?? []) {