@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 +26 -18
- package/dist/index.d.ts +26 -18
- package/dist/index.js +58 -27
- package/dist/index.mjs +58 -27
- package/package.json +1 -1
- package/src/utils/drag.ts +9 -5
- package/src/utils/recurrence.ts +77 -29
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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;
|
|
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;
|
|
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;
|
|
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. */
|
|
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. */
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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;
|
|
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;
|
|
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;
|
|
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. */
|
|
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. */
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
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
|
-
|
|
1072
|
-
if (
|
|
1073
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1113
|
-
if (
|
|
1114
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
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
|
-
|
|
1071
|
-
if (
|
|
1072
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
|
package/src/utils/recurrence.ts
CHANGED
|
@@ -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
|
|
12
|
-
// event); set `count`/`until`, or query a tighter
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
(
|
|
54
|
-
(
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
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
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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 ?? []) {
|