scschedule 2.1.1 → 3.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.
Files changed (34) hide show
  1. package/README.md +142 -41
  2. package/dist/cleanupExpiredOverridesFromSchedule.d.ts +4 -0
  3. package/dist/cleanupExpiredOverridesFromSchedule.js +4 -0
  4. package/dist/constants.d.ts +1 -3
  5. package/dist/constants.js +1 -3
  6. package/dist/getAvailableRangesFromSchedule.d.ts +10 -0
  7. package/dist/getAvailableRangesFromSchedule.js +19 -0
  8. package/dist/getNextAvailableFromSchedule.d.ts +9 -8
  9. package/dist/getNextAvailableFromSchedule.js +14 -8
  10. package/dist/getNextUnavailableFromSchedule.d.ts +19 -9
  11. package/dist/getNextUnavailableFromSchedule.js +124 -63
  12. package/dist/index.d.ts +3 -2
  13. package/dist/index.js +4 -2
  14. package/dist/internal/getApplicableRuleForDate.d.ts +11 -11
  15. package/dist/internal/getApplicableRuleForDate.js +11 -7
  16. package/dist/internal/index.d.ts +1 -3
  17. package/dist/internal/index.js +1 -3
  18. package/dist/internal/validateNoEmptyWeekdays.js +2 -1
  19. package/dist/internal/validateNoOverlappingRules.js +5 -4
  20. package/dist/internal/validateNoOverlappingTimesInRule.js +2 -1
  21. package/dist/internal/validateNoSpilloverConflictsAtOverrideBoundaries.d.ts +4 -2
  22. package/dist/internal/validateNoSpilloverConflictsAtOverrideBoundaries.js +71 -54
  23. package/dist/internal/validateNonEmptyTimes.js +2 -1
  24. package/dist/internal/validateScDateFormats.js +2 -1
  25. package/dist/isScheduleAvailable.d.ts +9 -0
  26. package/dist/isScheduleAvailable.js +19 -2
  27. package/dist/types.d.ts +12 -18
  28. package/dist/validateSchedule.d.ts +4 -2
  29. package/dist/validateSchedule.js +4 -4
  30. package/package.json +2 -2
  31. package/dist/internal/isValidTimezone.d.ts +0 -4
  32. package/dist/internal/isValidTimezone.js +0 -12
  33. package/dist/internal/validateTimezone.d.ts +0 -5
  34. package/dist/internal/validateTimezone.js +0 -16
@@ -1,31 +1,111 @@
1
1
  import { addDaysToDate, addMinutesToTimestamp, doesWeekdaysIncludeWeekday, getDateFromTimestamp, getTimeFromTimestamp, getTimestampFromDateAndTime, getWeekdayFromDate, isAfterTime, isBeforeTimestamp, isSameTimeOrAfter, sTimestamp, } from 'scdate';
2
2
  import { getApplicableRuleForDate } from './internal/getApplicableRuleForDate.js';
3
3
  import { isScheduleAvailable } from './isScheduleAvailable.js';
4
+ /**
5
+ * Collects "range end + 1 minute" candidate timestamps for a given date's
6
+ * rules. For cross-midnight ranges, the candidate falls on the next day.
7
+ * When afterTime is provided, regular (non-cross-midnight) ranges ending
8
+ * before afterTime are excluded.
9
+ */
10
+ const collectRangeEndCandidates = (rules, date, timeZone, afterTime) => {
11
+ const candidates = [];
12
+ const weekday = getWeekdayFromDate(date);
13
+ for (const rule of rules) {
14
+ if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
15
+ continue;
16
+ }
17
+ for (const timeRange of rule.times) {
18
+ if (isAfterTime(timeRange.from, timeRange.to)) {
19
+ // Cross-midnight range ends tomorrow
20
+ const tomorrow = addDaysToDate(date, 1);
21
+ const rangeEnd = getTimestampFromDateAndTime(tomorrow, timeRange.to);
22
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
23
+ }
24
+ else {
25
+ if (afterTime && !isSameTimeOrAfter(timeRange.to, afterTime)) {
26
+ continue;
27
+ }
28
+ const rangeEnd = getTimestampFromDateAndTime(date, timeRange.to);
29
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
30
+ }
31
+ }
32
+ }
33
+ return candidates;
34
+ };
35
+ /**
36
+ * Collects candidate timestamps from the previous day's cross-midnight ranges
37
+ * that spill into the current date. Only includes spillover endings at or
38
+ * after afterTime.
39
+ */
40
+ const collectSpilloverCandidates = (schedule, currentDate, timeZone, afterTime) => {
41
+ const candidates = [];
42
+ const previousDate = addDaysToDate(currentDate, -1);
43
+ const previousWeekday = getWeekdayFromDate(previousDate);
44
+ const previousResult = getApplicableRuleForDate(schedule, previousDate.date);
45
+ if (previousResult.rules === true) {
46
+ return candidates;
47
+ }
48
+ for (const rule of previousResult.rules) {
49
+ if (!doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
50
+ continue;
51
+ }
52
+ for (const timeRange of rule.times) {
53
+ // Only cross-midnight ranges (from > to) spill into today
54
+ if (!isAfterTime(timeRange.from, timeRange.to)) {
55
+ continue;
56
+ }
57
+ if (!isSameTimeOrAfter(timeRange.to, afterTime)) {
58
+ continue;
59
+ }
60
+ const rangeEnd = getTimestampFromDateAndTime(currentDate, timeRange.to);
61
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
62
+ }
63
+ }
64
+ return candidates;
65
+ };
66
+ /**
67
+ * Sorts timestamps chronologically and returns the first one where the
68
+ * schedule is unavailable, or undefined if all are available.
69
+ */
70
+ const findFirstUnavailableTimestamp = (schedule, candidates) => {
71
+ candidates.sort((a, b) => (isBeforeTimestamp(a, b) ? -1 : 1));
72
+ for (const candidate of candidates) {
73
+ if (!isScheduleAvailable(schedule, candidate)) {
74
+ return candidate;
75
+ }
76
+ }
77
+ return undefined;
78
+ };
4
79
  /**
5
80
  * Finds the next unavailable timestamp in a schedule starting from the
6
81
  * specified timestamp.
7
82
  *
8
83
  * This function searches forward from the given timestamp to find when the
9
84
  * schedule next becomes unavailable. It handles:
85
+ * - Always-available schedules (`weekly: true`) by searching for overrides
10
86
  * - Same-day unavailability (gaps between time ranges or after the last range)
11
87
  * - Cross-midnight ranges (unavailability after a range that crosses midnight)
12
88
  * - Date overrides (temporary closures)
13
89
  *
14
90
  * The algorithm works by:
15
91
  * 1. Checking if fromTimestamp is already unavailable
16
- * 2. Collecting all potential "end of availability" candidates from:
92
+ * 2. If `weekly` is `true`, searching forward day-by-day for an override that
93
+ * closes availability
94
+ * 3. Otherwise, collecting all potential "end of availability" candidates from:
17
95
  * - Previous day's cross-midnight spillover (ends today at `to` time)
18
96
  * - Current day's regular ranges (end today at `to` time)
19
97
  * - Current day's cross-midnight ranges (end tomorrow at `to` time)
20
- * 3. Sorting candidates and returning the earliest one that's unavailable
98
+ * 4. Sorting candidates and returning the earliest one that's unavailable
21
99
  *
22
- * Note: No day-by-day iteration is needed because if we're available, we must
23
- * be in some range, and that range ends within at most 24 hours (or ~48 hours
24
- * for cross-midnight ranges).
100
+ * Note: For rule-based schedules, no day-by-day iteration is needed because if
101
+ * available, the current range ends within at most ~48 hours (cross-midnight).
25
102
  *
26
- * @param schedule - The schedule to check availability against
27
- * @param fromTimestamp - The starting timestamp to search from
28
- * @returns The next unavailable timestamp, or undefined if always available
103
+ * @param schedule The schedule to check availability against.
104
+ * @param timeZone IANA time zone identifier for timestamp arithmetic.
105
+ * @param fromTimestamp The starting timestamp to search from.
106
+ * @param maxDaysToSearch Maximum number of days to search forward.
107
+ * @returns The next unavailable timestamp, or undefined if no unavailability
108
+ * is found within the search window.
29
109
  *
30
110
  * @example
31
111
  * // Schedule: Mon-Fri 09:00-17:00
@@ -41,8 +121,13 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
41
121
  * // Schedule: Thu-Sat 20:00-02:00 (cross-midnight)
42
122
  * // Query: Thursday at 23:00
43
123
  * // Returns: Friday at 02:01 (after shift ends)
124
+ *
125
+ * @example
126
+ * // Schedule: weekly: true, override closing Dec 25
127
+ * // Query: Dec 20 at 10:00
128
+ * // Returns: Dec 25 at 00:00
44
129
  */
45
- export const getNextUnavailableFromSchedule = (schedule, fromTimestamp) => {
130
+ export const getNextUnavailableFromSchedule = (schedule, timeZone, fromTimestamp, maxDaysToSearch) => {
46
131
  const initialTimestamp = sTimestamp(fromTimestamp);
47
132
  // Check if already unavailable at fromTimestamp
48
133
  if (!isScheduleAvailable(schedule, initialTimestamp)) {
@@ -50,63 +135,39 @@ export const getNextUnavailableFromSchedule = (schedule, fromTimestamp) => {
50
135
  }
51
136
  const currentDate = getDateFromTimestamp(initialTimestamp);
52
137
  const fromTime = getTimeFromTimestamp(initialTimestamp);
53
- // Collect all candidate "end + 1 minute" timestamps
54
- const candidates = [];
55
- // 1. Check previous day's cross-midnight spillover into today
56
- const previousDate = addDaysToDate(currentDate, -1);
57
- const previousWeekday = getWeekdayFromDate(previousDate);
58
- const { rules: previousRules } = getApplicableRuleForDate(schedule, previousDate.date);
59
- for (const rule of previousRules) {
60
- if (!doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
61
- continue;
62
- }
63
- for (const timeRange of rule.times) {
64
- // Cross-midnight range (from > to) spills into today
65
- if (!isAfterTime(timeRange.from, timeRange.to)) {
66
- continue;
67
- }
68
- // Spillover ends today at timeRange.to
69
- // Only consider if end is at or after current time
70
- if (!isSameTimeOrAfter(timeRange.to, fromTime)) {
71
- continue;
72
- }
73
- const rangeEnd = getTimestampFromDateAndTime(currentDate, timeRange.to);
74
- const afterRangeEnd = addMinutesToTimestamp(rangeEnd, 1, schedule.timezone);
75
- candidates.push(afterRangeEnd);
76
- }
77
- }
78
- // 2. Check current day's ranges
79
- const weekday = getWeekdayFromDate(currentDate);
80
- const { rules } = getApplicableRuleForDate(schedule, currentDate.date);
81
- for (const rule of rules) {
82
- if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
83
- continue;
84
- }
85
- for (const timeRange of rule.times) {
86
- // Cross-midnight range (from > to) ends TOMORROW
87
- if (isAfterTime(timeRange.from, timeRange.to)) {
88
- const tomorrow = addDaysToDate(currentDate, 1);
89
- const rangeEnd = getTimestampFromDateAndTime(tomorrow, timeRange.to);
90
- const afterRangeEnd = addMinutesToTimestamp(rangeEnd, 1, schedule.timezone);
91
- candidates.push(afterRangeEnd);
92
- }
93
- else {
94
- // Regular range ends today - only consider if end >= current time
95
- if (!isSameTimeOrAfter(timeRange.to, fromTime)) {
96
- continue;
138
+ // When weekly is true (always available), search forward for an override
139
+ // that closes availability.
140
+ const initialResult = getApplicableRuleForDate(schedule, currentDate.date);
141
+ if (initialResult.rules === true) {
142
+ // rules === true implies source === 'weekly', so skip current date
143
+ // and start searching from the next day.
144
+ let searchDate = addDaysToDate(currentDate, 1);
145
+ for (let day = 1; day < maxDaysToSearch; day++) {
146
+ const result = getApplicableRuleForDate(schedule, searchDate.date);
147
+ // If we are here it means that weekly is true, so we need to search for
148
+ // an override that closes availability.
149
+ if (result.source === 'override') {
150
+ const dayStart = getTimestampFromDateAndTime(searchDate, '00:00');
151
+ const nextUnavailable = findFirstUnavailableTimestamp(schedule, [
152
+ dayStart,
153
+ ...collectRangeEndCandidates(result.rules, searchDate, timeZone),
154
+ ]);
155
+ if (nextUnavailable) {
156
+ return nextUnavailable;
97
157
  }
98
- const rangeEnd = getTimestampFromDateAndTime(currentDate, timeRange.to);
99
- const afterRangeEnd = addMinutesToTimestamp(rangeEnd, 1, schedule.timezone);
100
- candidates.push(afterRangeEnd);
101
158
  }
159
+ searchDate = addDaysToDate(searchDate, 1);
102
160
  }
161
+ return undefined;
103
162
  }
104
- // Sort candidates chronologically and find first that's actually unavailable
105
- candidates.sort((a, b) => (isBeforeTimestamp(a, b) ? -1 : 1));
106
- for (const candidate of candidates) {
107
- if (!isScheduleAvailable(schedule, candidate)) {
108
- return candidate;
109
- }
163
+ // Collect candidates from previous day's cross-midnight spillover and
164
+ // current day's ranges, then return the earliest unavailable one.
165
+ const { rules } = getApplicableRuleForDate(schedule, currentDate.date);
166
+ if (rules === true) {
167
+ return undefined;
110
168
  }
111
- return undefined;
169
+ return findFirstUnavailableTimestamp(schedule, [
170
+ ...collectSpilloverCandidates(schedule, currentDate, timeZone, fromTime),
171
+ ...collectRangeEndCandidates(rules, currentDate, timeZone, fromTime),
172
+ ]);
112
173
  };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
+ export { isValidTimeZone } from 'scdate';
1
2
  export type { SDate, STime, STimestamp, SWeekdays } from 'scdate';
2
3
  export * from './constants.js';
3
4
  export type * from './types.js';
4
5
  export * from './validateSchedule.js';
5
6
  export * from './cleanupExpiredOverridesFromSchedule.js';
6
- export * from './isScheduleAvailable.js';
7
+ export * from './getAvailableRangesFromSchedule.js';
7
8
  export * from './getNextAvailableFromSchedule.js';
8
9
  export * from './getNextUnavailableFromSchedule.js';
9
- export * from './getAvailableRangesFromSchedule.js';
10
+ export * from './isScheduleAvailable.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Re-export scdate types and utilities for convenience
2
+ export { isValidTimeZone } from 'scdate';
1
3
  // Export constants
2
4
  export * from './constants.js';
3
5
  // Export validation functions
@@ -5,7 +7,7 @@ export * from './validateSchedule.js';
5
7
  // Export schedule management functions
6
8
  export * from './cleanupExpiredOverridesFromSchedule.js';
7
9
  // Export availability query functions
8
- export * from './isScheduleAvailable.js';
10
+ export * from './getAvailableRangesFromSchedule.js';
9
11
  export * from './getNextAvailableFromSchedule.js';
10
12
  export * from './getNextUnavailableFromSchedule.js';
11
- export * from './getAvailableRangesFromSchedule.js';
13
+ export * from './isScheduleAvailable.js';
@@ -1,24 +1,24 @@
1
1
  import { SDate } from 'scdate';
2
2
  import type { Schedule, SDateString, WeeklyScheduleRule } from '../types.js';
3
- export type RuleSource = {
4
- type: 'weekly';
3
+ export type ApplicableRule = {
4
+ source: 'weekly';
5
+ rules: WeeklyScheduleRule[] | true;
5
6
  } | {
6
- type: 'override';
7
+ source: 'override';
7
8
  overrideIndex: number;
8
- };
9
- export interface RuleWithSource {
10
9
  rules: WeeklyScheduleRule[];
11
- source: RuleSource;
12
- }
10
+ };
13
11
  /**
14
12
  * Determines which rules apply for a given date based on overrides or weekly
15
- * schedule, and returns the source information (weekly vs which override
16
- * index). When multiple overrides apply, selects the most specific (shortest)
17
- * duration).
13
+ * schedule. Returns a discriminated union indicating the source (`'weekly'` or
14
+ * `'override'`) and the applicable rules. When the source is `'weekly'` and
15
+ * the schedule has `weekly: true`, the returned `rules` will be `true`.
16
+ *
17
+ * When multiple overrides apply, selects the most specific (shortest duration).
18
18
  *
19
19
  * Priority:
20
20
  * 1. Specific overrides (with 'to' date) - shortest duration wins
21
21
  * 2. Indefinite overrides (no 'to' date) - latest 'from' date wins
22
22
  * 3. Weekly schedule (fallback)
23
23
  */
24
- export declare const getApplicableRuleForDate: (schedule: Schedule, date: SDate | SDateString) => RuleWithSource;
24
+ export declare const getApplicableRuleForDate: (schedule: Schedule, date: SDate | SDateString) => ApplicableRule;
@@ -1,9 +1,11 @@
1
1
  import { getDaysBetweenDates, isSameDateOrAfter, isSameDateOrBefore, } from 'scdate';
2
2
  /**
3
3
  * Determines which rules apply for a given date based on overrides or weekly
4
- * schedule, and returns the source information (weekly vs which override
5
- * index). When multiple overrides apply, selects the most specific (shortest)
6
- * duration).
4
+ * schedule. Returns a discriminated union indicating the source (`'weekly'` or
5
+ * `'override'`) and the applicable rules. When the source is `'weekly'` and
6
+ * the schedule has `weekly: true`, the returned `rules` will be `true`.
7
+ *
8
+ * When multiple overrides apply, selects the most specific (shortest duration).
7
9
  *
8
10
  * Priority:
9
11
  * 1. Specific overrides (with 'to' date) - shortest duration wins
@@ -15,8 +17,8 @@ export const getApplicableRuleForDate = (schedule, date) => {
15
17
  // schedule)
16
18
  if (!schedule.overrides || schedule.overrides.length === 0) {
17
19
  return {
20
+ source: 'weekly',
18
21
  rules: schedule.weekly,
19
- source: { type: 'weekly' },
20
22
  };
21
23
  }
22
24
  // - Check the case where there are overrides and collect all specific
@@ -48,8 +50,9 @@ export const getApplicableRuleForDate = (schedule, date) => {
48
50
  return currentDuration < shortestDuration ? current : shortest;
49
51
  });
50
52
  return {
53
+ source: 'override',
54
+ overrideIndex: mostSpecific.index,
51
55
  rules: mostSpecific.override.rules,
52
- source: { type: 'override', overrideIndex: mostSpecific.index },
53
56
  };
54
57
  }
55
58
  // - At this point, we know there are no applicable overrides with from and
@@ -70,13 +73,14 @@ export const getApplicableRuleForDate = (schedule, date) => {
70
73
  : latest;
71
74
  });
72
75
  return {
76
+ source: 'override',
77
+ overrideIndex: mostRecent.index,
73
78
  rules: mostRecent.override.rules,
74
- source: { type: 'override', overrideIndex: mostRecent.index },
75
79
  };
76
80
  }
77
81
  // - If there are no applicable indefinite overrides, return the weekly rules
78
82
  return {
83
+ source: 'weekly',
79
84
  rules: schedule.weekly,
80
- source: { type: 'weekly' },
81
85
  };
82
86
  };
@@ -4,16 +4,14 @@ export * from './doTimeRangesOverlap.js';
4
4
  export * from './getApplicableRuleForDate.js';
5
5
  export * from './getEffectiveTimesForWeekday.js';
6
6
  export * from './isTimeInTimeRange.js';
7
- export * from './isValidTimezone.js';
8
7
  export * from './normalizeScheduleForValidation.js';
9
8
  export * from './splitCrossMidnightTimeRange.js';
10
9
  export * from './validateNoEmptyWeekdays.js';
10
+ export * from './validateNonEmptyTimes.js';
11
11
  export * from './validateNoOverlappingOverrides.js';
12
12
  export * from './validateNoOverlappingRules.js';
13
13
  export * from './validateNoOverlappingTimesInRule.js';
14
14
  export * from './validateNoSpilloverConflictsAtOverrideBoundaries.js';
15
- export * from './validateNonEmptyTimes.js';
16
15
  export * from './validateOverrideDateOrder.js';
17
16
  export * from './validateOverrideWeekdaysMatchDates.js';
18
17
  export * from './validateScDateFormats.js';
19
- export * from './validateTimezone.js';
@@ -5,17 +5,15 @@ export * from './doTimeRangesOverlap.js';
5
5
  export * from './getApplicableRuleForDate.js';
6
6
  export * from './getEffectiveTimesForWeekday.js';
7
7
  export * from './isTimeInTimeRange.js';
8
- export * from './isValidTimezone.js';
9
8
  export * from './normalizeScheduleForValidation.js';
10
9
  export * from './splitCrossMidnightTimeRange.js';
11
10
  // Internal validation helpers
12
11
  export * from './validateNoEmptyWeekdays.js';
12
+ export * from './validateNonEmptyTimes.js';
13
13
  export * from './validateNoOverlappingOverrides.js';
14
14
  export * from './validateNoOverlappingRules.js';
15
15
  export * from './validateNoOverlappingTimesInRule.js';
16
16
  export * from './validateNoSpilloverConflictsAtOverrideBoundaries.js';
17
- export * from './validateNonEmptyTimes.js';
18
17
  export * from './validateOverrideDateOrder.js';
19
18
  export * from './validateOverrideWeekdaysMatchDates.js';
20
19
  export * from './validateScDateFormats.js';
21
- export * from './validateTimezone.js';
@@ -8,7 +8,8 @@ import { RuleLocationType, ValidationIssue } from '../constants.js';
8
8
  export const validateNoEmptyWeekdays = (schedule) => {
9
9
  const errors = [];
10
10
  // Check weekly rules
11
- schedule.weekly.forEach((rule, ruleIndex) => {
11
+ const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
12
+ weeklyRules.forEach((rule, ruleIndex) => {
12
13
  try {
13
14
  if (isWeekdaysEmpty(rule.weekdays)) {
14
15
  errors.push({
@@ -14,10 +14,11 @@ import { doRulesOverlap } from './doRulesOverlap.js';
14
14
  export const validateNoOverlappingRules = (schedule) => {
15
15
  const errors = [];
16
16
  // Check weekly rules for overlaps
17
- for (let i = 0; i < schedule.weekly.length; i++) {
18
- for (let j = i + 1; j < schedule.weekly.length; j++) {
19
- const rule1 = schedule.weekly[i];
20
- const rule2 = schedule.weekly[j];
17
+ const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
18
+ for (let i = 0; i < weeklyRules.length; i++) {
19
+ for (let j = i + 1; j < weeklyRules.length; j++) {
20
+ const rule1 = weeklyRules[i];
21
+ const rule2 = weeklyRules[j];
21
22
  if (rule1 && rule2) {
22
23
  const overlapWeekday = doRulesOverlap(rule1, rule2);
23
24
  if (overlapWeekday !== undefined) {
@@ -32,7 +32,8 @@ const validateRuleTimes = (rule, location) => {
32
32
  export const validateNoOverlappingTimesInRule = (schedule) => {
33
33
  const errors = [];
34
34
  // Validate weekly rules
35
- schedule.weekly.forEach((rule, ruleIndex) => {
35
+ const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
36
+ weeklyRules.forEach((rule, ruleIndex) => {
36
37
  const ruleErrors = validateRuleTimes(rule, {
37
38
  type: RuleLocationType.Weekly,
38
39
  ruleIndex,
@@ -3,9 +3,11 @@ import type { Schedule, ValidationError } from '../types.js';
3
3
  * Validates that cross-midnight spillover at override boundaries doesn't
4
4
  * create overlapping time ranges. Checks:
5
5
  * 1. Spillover into override first day (from weekly or previous override)
6
- * 2. Override last day spillover into next day (weekly or another override)
6
+ * 2. Override last day spillover into next day (weekly, weekly: true, or
7
+ * another override)
7
8
  *
8
9
  * Note: Spillover into empty/closed days is allowed (adds availability).
9
- * Only spillover that OVERLAPS with explicit time ranges is flagged as error.
10
+ * Spillover that overlaps with explicit time ranges OR into weekly: true
11
+ * days (which are fully available) is flagged as an error.
10
12
  */
11
13
  export declare const validateNoSpilloverConflictsAtOverrideBoundaries: (schedule: Schedule) => ValidationError[];
@@ -7,10 +7,12 @@ import { splitCrossMidnightTimeRange } from './splitCrossMidnightTimeRange.js';
7
7
  * Validates that cross-midnight spillover at override boundaries doesn't
8
8
  * create overlapping time ranges. Checks:
9
9
  * 1. Spillover into override first day (from weekly or previous override)
10
- * 2. Override last day spillover into next day (weekly or another override)
10
+ * 2. Override last day spillover into next day (weekly, weekly: true, or
11
+ * another override)
11
12
  *
12
13
  * Note: Spillover into empty/closed days is allowed (adds availability).
13
- * Only spillover that OVERLAPS with explicit time ranges is flagged as error.
14
+ * Spillover that overlaps with explicit time ranges OR into weekly: true
15
+ * days (which are fully available) is flagged as an error.
14
16
  */
15
17
  export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
16
18
  const errors = [];
@@ -27,59 +29,63 @@ export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
27
29
  // including indefinite ones)
28
30
  // Get rules that apply to previous day (weekly or override)
29
31
  const previousDayResult = getApplicableRuleForDate(schedule, previousDate);
30
- // Check each rule from previous day for cross-midnight spillover
31
- previousDayResult.rules.forEach((previousDayRule, previousDayRuleIndex) => {
32
- // Skip if previous day rule doesn't include previous weekday
33
- if (!doesWeekdaysIncludeWeekday(previousDayRule.weekdays, previousWeekday)) {
34
- return;
35
- }
36
- // Check each time range for cross-midnight
37
- previousDayRule.times.forEach((timeRange) => {
38
- const splitRanges = splitCrossMidnightTimeRange(timeRange);
39
- // If cross-midnight, splitRanges[1] is the spillover portion
40
- if (splitRanges.length === 2 && splitRanges[1]) {
41
- const spilloverRange = splitRanges[1];
42
- // Check if spillover conflicts with override first day's times
43
- override.rules.forEach((overrideRule, overrideRuleIndex) => {
44
- // Does first day match override rule's weekdays?
45
- if (!doesWeekdaysIncludeWeekday(overrideRule.weekdays, firstDateWeekday)) {
46
- return;
47
- }
48
- // Check each time range in override rule
49
- overrideRule.times.forEach((overrideTimeRange) => {
50
- const overrideSplitRanges = splitCrossMidnightTimeRange(overrideTimeRange);
51
- // Check same-day portion of override time range
52
- const overrideSameDayRange = overrideSplitRanges[0];
53
- if (overrideSameDayRange &&
54
- doTimeRangesOverlap(spilloverRange, overrideSameDayRange)) {
55
- // Use source information from getApplicableRuleForDate
56
- if (previousDayResult.source.type === 'override') {
57
- // Previous day is in an override
58
- errors.push({
59
- issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
60
- overrideIndex,
61
- date: firstDate.toJSON(),
62
- overrideRuleIndex,
63
- sourceOverrideIndex: previousDayResult.source.overrideIndex,
64
- sourceOverrideRuleIndex: previousDayRuleIndex,
65
- });
66
- }
67
- else {
68
- // Previous day is weekly
69
- errors.push({
70
- issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
71
- overrideIndex,
72
- date: firstDate.toJSON(),
73
- overrideRuleIndex,
74
- sourceWeeklyRuleIndex: previousDayRuleIndex,
75
- });
76
- }
32
+ // If previous day is always available (weekly: true), no cross-midnight
33
+ // rules to check
34
+ if (previousDayResult.rules !== true) {
35
+ // Check each rule from previous day for cross-midnight spillover
36
+ previousDayResult.rules.forEach((previousDayRule, previousDayRuleIndex) => {
37
+ // Skip if previous day rule doesn't include previous weekday
38
+ if (!doesWeekdaysIncludeWeekday(previousDayRule.weekdays, previousWeekday)) {
39
+ return;
40
+ }
41
+ // Check each time range for cross-midnight
42
+ previousDayRule.times.forEach((timeRange) => {
43
+ const splitRanges = splitCrossMidnightTimeRange(timeRange);
44
+ // If cross-midnight, splitRanges[1] is the spillover portion
45
+ if (splitRanges.length === 2 && splitRanges[1]) {
46
+ const spilloverRange = splitRanges[1];
47
+ // Check if spillover conflicts with override first day's times
48
+ override.rules.forEach((overrideRule, overrideRuleIndex) => {
49
+ // Does first day match override rule's weekdays?
50
+ if (!doesWeekdaysIncludeWeekday(overrideRule.weekdays, firstDateWeekday)) {
51
+ return;
77
52
  }
53
+ // Check each time range in override rule
54
+ overrideRule.times.forEach((overrideTimeRange) => {
55
+ const overrideSplitRanges = splitCrossMidnightTimeRange(overrideTimeRange);
56
+ // Check same-day portion of override time range
57
+ const overrideSameDayRange = overrideSplitRanges[0];
58
+ if (overrideSameDayRange &&
59
+ doTimeRangesOverlap(spilloverRange, overrideSameDayRange)) {
60
+ // Use source information from getApplicableRuleForDate
61
+ if (previousDayResult.source === 'override') {
62
+ // Previous day is in an override
63
+ errors.push({
64
+ issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
65
+ overrideIndex,
66
+ date: firstDate.toJSON(),
67
+ overrideRuleIndex,
68
+ sourceOverrideIndex: previousDayResult.overrideIndex,
69
+ sourceOverrideRuleIndex: previousDayRuleIndex,
70
+ });
71
+ }
72
+ else {
73
+ // Previous day is weekly
74
+ errors.push({
75
+ issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
76
+ overrideIndex,
77
+ date: firstDate.toJSON(),
78
+ overrideRuleIndex,
79
+ sourceWeeklyRuleIndex: previousDayRuleIndex,
80
+ });
81
+ }
82
+ }
83
+ });
78
84
  });
79
- });
80
- }
85
+ }
86
+ });
81
87
  });
82
- });
88
+ }
83
89
  // PART 2: Check spillover FROM override last day into next day
84
90
  // Skip for indefinite overrides (no last day)
85
91
  if (!override.to) {
@@ -103,6 +109,17 @@ export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
103
109
  const spilloverRange = splitRanges[1];
104
110
  // Get what rule applies to next day
105
111
  const nextDayResult = getApplicableRuleForDate(schedule, nextDate);
112
+ // If next day is always available (weekly: true), spillover
113
+ // creates overlapping ranges with the full-day availability.
114
+ if (nextDayResult.rules === true) {
115
+ errors.push({
116
+ issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
117
+ overrideIndex,
118
+ date: lastDate.toJSON(),
119
+ overrideRuleIndex,
120
+ });
121
+ return;
122
+ }
106
123
  // Check if spillover conflicts with next day's times
107
124
  nextDayResult.rules.forEach((nextDayRule, nextDayRuleIndex) => {
108
125
  // Does next day match the rule's weekdays?
@@ -117,14 +134,14 @@ export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
117
134
  if (nextDaySameDayRange &&
118
135
  doTimeRangesOverlap(spilloverRange, nextDaySameDayRange)) {
119
136
  // Use source information from getApplicableRuleForDate
120
- if (nextDayResult.source.type === 'override') {
137
+ if (nextDayResult.source === 'override') {
121
138
  // Next day is in an override
122
139
  errors.push({
123
140
  issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
124
141
  overrideIndex,
125
142
  date: lastDate.toJSON(),
126
143
  overrideRuleIndex,
127
- nextDayOverrideIndex: nextDayResult.source.overrideIndex,
144
+ nextDayOverrideIndex: nextDayResult.overrideIndex,
128
145
  nextDayOverrideRuleIndex: nextDayRuleIndex,
129
146
  });
130
147
  }
@@ -5,7 +5,8 @@ import { RuleLocationType, ValidationIssue } from '../constants.js';
5
5
  export const validateNonEmptyTimes = (schedule) => {
6
6
  const errors = [];
7
7
  // Validate weekly rules
8
- schedule.weekly.forEach((rule, ruleIndex) => {
8
+ const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
9
+ weeklyRules.forEach((rule, ruleIndex) => {
9
10
  if (rule.times.length === 0) {
10
11
  errors.push({
11
12
  issue: ValidationIssue.EmptyTimes,
@@ -86,7 +86,8 @@ const validateRule = (rule, fieldPrefix) => {
86
86
  export const validateScDateFormats = (schedule) => {
87
87
  const errors = [];
88
88
  // Validate weekly rules
89
- schedule.weekly.forEach((rule, ruleIndex) => {
89
+ const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
90
+ weeklyRules.forEach((rule, ruleIndex) => {
90
91
  errors.push(...validateRule(rule, `weekly[${String(ruleIndex)}]`));
91
92
  });
92
93
  // Validate overrides