scschedule 2.1.1 → 3.1.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 (44) hide show
  1. package/README.md +161 -74
  2. package/dist/cleanupExpiredOverridesFromSchedule.d.ts +4 -0
  3. package/dist/cleanupExpiredOverridesFromSchedule.js +4 -0
  4. package/dist/constants.d.ts +1 -10
  5. package/dist/constants.js +1 -10
  6. package/dist/getAvailableRangesFromSchedule.d.ts +10 -0
  7. package/dist/getAvailableRangesFromSchedule.js +29 -13
  8. package/dist/getNextAvailableFromSchedule.d.ts +9 -8
  9. package/dist/getNextAvailableFromSchedule.js +21 -17
  10. package/dist/getNextUnavailableFromSchedule.d.ts +19 -9
  11. package/dist/getNextUnavailableFromSchedule.js +120 -63
  12. package/dist/index.d.ts +3 -2
  13. package/dist/index.js +4 -2
  14. package/dist/internal/doTimeRangesOverlap.d.ts +1 -1
  15. package/dist/internal/getApplicableRuleForDate.d.ts +11 -11
  16. package/dist/internal/getApplicableRuleForDate.js +11 -7
  17. package/dist/internal/getEffectiveTimesForWeekday.d.ts +2 -1
  18. package/dist/internal/getEffectiveTimesForWeekday.js +9 -14
  19. package/dist/internal/index.d.ts +1 -4
  20. package/dist/internal/index.js +0 -4
  21. package/dist/internal/isTimeInTimeRange.d.ts +2 -1
  22. package/dist/internal/splitCrossMidnightTimeRange.d.ts +1 -1
  23. package/dist/internal/splitCrossMidnightTimeRange.js +3 -2
  24. package/dist/internal/types.d.ts +13 -0
  25. package/dist/internal/types.js +1 -0
  26. package/dist/internal/validateNoEmptyWeekdays.js +2 -1
  27. package/dist/internal/validateNoOverlappingRules.js +5 -4
  28. package/dist/internal/validateNoSpilloverConflictsAtOverrideBoundaries.d.ts +4 -2
  29. package/dist/internal/validateNoSpilloverConflictsAtOverrideBoundaries.js +95 -88
  30. package/dist/internal/validateScDateFormats.js +4 -5
  31. package/dist/isScheduleAvailable.d.ts +9 -0
  32. package/dist/isScheduleAvailable.js +23 -6
  33. package/dist/types.d.ts +18 -64
  34. package/dist/validateSchedule.d.ts +4 -2
  35. package/dist/validateSchedule.js +4 -8
  36. package/package.json +2 -2
  37. package/dist/internal/isValidTimezone.d.ts +0 -4
  38. package/dist/internal/isValidTimezone.js +0 -12
  39. package/dist/internal/validateNoOverlappingTimesInRule.d.ts +0 -5
  40. package/dist/internal/validateNoOverlappingTimesInRule.js +0 -54
  41. package/dist/internal/validateNonEmptyTimes.d.ts +0 -5
  42. package/dist/internal/validateNonEmptyTimes.js +0 -35
  43. package/dist/internal/validateTimezone.d.ts +0 -5
  44. package/dist/internal/validateTimezone.js +0 -16
@@ -7,6 +7,7 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
7
7
  *
8
8
  * This function searches forward from the given timestamp to find when the
9
9
  * schedule next becomes available. It handles:
10
+ * - Always-available days (`weekly: true` or `rules: true` from an override)
10
11
  * - Same-day availability (finding the next time range on the current day)
11
12
  * - Cross-midnight spillover (ranges that extend past midnight are detected
12
13
  * via isScheduleAvailable)
@@ -16,8 +17,9 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
16
17
  * The algorithm works by:
17
18
  * 1. Checking if fromTimestamp is already available (including spillover from
18
19
  * the previous day's cross-midnight ranges)
19
- * 2. If not, finding the earliest time range start on the current day that
20
- * occurs after fromTimestamp
20
+ * 2. If not, iterating day-by-day:
21
+ * - If rules are `true`, the entire day is available (returns 00:00)
22
+ * - Otherwise, finding the earliest time range start after fromTimestamp
21
23
  * 3. If no ranges found on current day, moving to the next day and repeating
22
24
  *
23
25
  * Note: Spillover ranges don't need explicit tracking because:
@@ -27,12 +29,11 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
27
29
  * - The "next available" time is always a time range start, never a spillover
28
30
  * timestamp
29
31
  *
30
- * @param schedule - The schedule to check availability against
31
- * @param fromTimestamp - The starting timestamp to search from
32
- * @param maxDaysToSearch - Maximum number of days to search forward (default:
33
- * 365)
32
+ * @param schedule The schedule to check availability against.
33
+ * @param fromTimestamp The starting timestamp to search from.
34
+ * @param maxDaysToSearch Maximum number of days to search forward.
34
35
  * @returns The next available timestamp, or undefined if none found within
35
- * the search window
36
+ * the search window.
36
37
  *
37
38
  * @example
38
39
  * // Schedule: Mon-Fri 09:00-17:00
@@ -53,7 +54,7 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
53
54
  * // Custom search window: only search 30 days ahead
54
55
  * getNextAvailableFromSchedule(schedule, timestamp, 30)
55
56
  */
56
- export const getNextAvailableFromSchedule = (schedule, fromTimestamp, maxDaysToSearch = 365) => {
57
+ export const getNextAvailableFromSchedule = (schedule, fromTimestamp, maxDaysToSearch) => {
57
58
  const initialTimestamp = sTimestamp(fromTimestamp);
58
59
  // Check if already available at fromTimestamp (handles spillover too)
59
60
  if (isScheduleAvailable(schedule, initialTimestamp)) {
@@ -65,21 +66,24 @@ export const getNextAvailableFromSchedule = (schedule, fromTimestamp, maxDaysToS
65
66
  for (let day = 0; day < maxDaysToSearch; day++) {
66
67
  const weekday = getWeekdayFromDate(currentDate);
67
68
  const { rules } = getApplicableRuleForDate(schedule, currentDate.date);
69
+ // If rules are true, the entire day is available. This only happens on
70
+ // day 1+ because on day 0, isScheduleAvailable would have returned true.
71
+ if (rules === true) {
72
+ return getTimestampFromDateAndTime(currentDate, '00:00');
73
+ }
68
74
  // Track earliest time
69
75
  let earliestTime;
70
76
  for (const rule of rules) {
71
77
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
72
78
  continue;
73
79
  }
74
- for (const timeRange of rule.times) {
75
- // Day 0: only consider ranges starting after fromTimestamp's time
76
- // Day 1+: consider all ranges (any start time qualifies)
77
- if (day === 0 && !isAfterTime(timeRange.from, fromTime)) {
78
- continue;
79
- }
80
- if (!earliestTime || isBeforeTime(timeRange.from, earliestTime)) {
81
- earliestTime = timeRange.from;
82
- }
80
+ // Day 0: only consider ranges starting after fromTimestamp's time
81
+ // Day 1+: consider all ranges (any start time qualifies)
82
+ if (day === 0 && !isAfterTime(rule.from, fromTime)) {
83
+ continue;
84
+ }
85
+ if (!earliestTime || isBeforeTime(rule.from, earliestTime)) {
86
+ earliestTime = rule.from;
83
87
  }
84
88
  }
85
89
  if (earliestTime) {
@@ -6,25 +6,30 @@ import type { Schedule } from './types.js';
6
6
  *
7
7
  * This function searches forward from the given timestamp to find when the
8
8
  * schedule next becomes unavailable. It handles:
9
+ * - Always-available schedules (`weekly: true`) by searching for overrides
9
10
  * - Same-day unavailability (gaps between time ranges or after the last range)
10
11
  * - Cross-midnight ranges (unavailability after a range that crosses midnight)
11
12
  * - Date overrides (temporary closures)
12
13
  *
13
14
  * The algorithm works by:
14
15
  * 1. Checking if fromTimestamp is already unavailable
15
- * 2. Collecting all potential "end of availability" candidates from:
16
+ * 2. If `weekly` is `true`, searching forward day-by-day for an override that
17
+ * closes availability
18
+ * 3. Otherwise, collecting all potential "end of availability" candidates from:
16
19
  * - Previous day's cross-midnight spillover (ends today at `to` time)
17
20
  * - Current day's regular ranges (end today at `to` time)
18
21
  * - Current day's cross-midnight ranges (end tomorrow at `to` time)
19
- * 3. Sorting candidates and returning the earliest one that's unavailable
22
+ * 4. Sorting candidates and returning the earliest one that's unavailable
20
23
  *
21
- * Note: No day-by-day iteration is needed because if we're available, we must
22
- * be in some range, and that range ends within at most 24 hours (or ~48 hours
23
- * for cross-midnight ranges).
24
+ * Note: For rule-based schedules, no day-by-day iteration is needed because if
25
+ * available, the current range ends within at most ~48 hours (cross-midnight).
24
26
  *
25
- * @param schedule - The schedule to check availability against
26
- * @param fromTimestamp - The starting timestamp to search from
27
- * @returns The next unavailable timestamp, or undefined if always available
27
+ * @param schedule The schedule to check availability against.
28
+ * @param timeZone IANA time zone identifier for timestamp arithmetic.
29
+ * @param fromTimestamp The starting timestamp to search from.
30
+ * @param maxDaysToSearch Maximum number of days to search forward.
31
+ * @returns The next unavailable timestamp, or undefined if no unavailability
32
+ * is found within the search window.
28
33
  *
29
34
  * @example
30
35
  * // Schedule: Mon-Fri 09:00-17:00
@@ -40,5 +45,10 @@ import type { Schedule } from './types.js';
40
45
  * // Schedule: Thu-Sat 20:00-02:00 (cross-midnight)
41
46
  * // Query: Thursday at 23:00
42
47
  * // Returns: Friday at 02:01 (after shift ends)
48
+ *
49
+ * @example
50
+ * // Schedule: weekly: true, override closing Dec 25
51
+ * // Query: Dec 20 at 10:00
52
+ * // Returns: Dec 25 at 00:00
43
53
  */
44
- export declare const getNextUnavailableFromSchedule: (schedule: Schedule, fromTimestamp: STimestamp | string) => STimestamp | undefined;
54
+ export declare const getNextUnavailableFromSchedule: (schedule: Schedule, timeZone: string, fromTimestamp: STimestamp | string, maxDaysToSearch: number) => STimestamp | undefined;
@@ -1,31 +1,107 @@
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
+ if (isAfterTime(rule.from, rule.to)) {
18
+ // Cross-midnight range ends tomorrow
19
+ const tomorrow = addDaysToDate(date, 1);
20
+ const rangeEnd = getTimestampFromDateAndTime(tomorrow, rule.to);
21
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
22
+ }
23
+ else {
24
+ if (afterTime && !isSameTimeOrAfter(rule.to, afterTime)) {
25
+ continue;
26
+ }
27
+ const rangeEnd = getTimestampFromDateAndTime(date, rule.to);
28
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
29
+ }
30
+ }
31
+ return candidates;
32
+ };
33
+ /**
34
+ * Collects candidate timestamps from the previous day's cross-midnight ranges
35
+ * that spill into the current date. Only includes spillover endings at or
36
+ * after afterTime.
37
+ */
38
+ const collectSpilloverCandidates = (schedule, currentDate, timeZone, afterTime) => {
39
+ const candidates = [];
40
+ const previousDate = addDaysToDate(currentDate, -1);
41
+ const previousWeekday = getWeekdayFromDate(previousDate);
42
+ const previousResult = getApplicableRuleForDate(schedule, previousDate.date);
43
+ if (previousResult.rules === true) {
44
+ return candidates;
45
+ }
46
+ for (const rule of previousResult.rules) {
47
+ if (!doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
48
+ continue;
49
+ }
50
+ // Only cross-midnight ranges (from > to) spill into today
51
+ if (!isAfterTime(rule.from, rule.to)) {
52
+ continue;
53
+ }
54
+ if (!isSameTimeOrAfter(rule.to, afterTime)) {
55
+ continue;
56
+ }
57
+ const rangeEnd = getTimestampFromDateAndTime(currentDate, rule.to);
58
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
59
+ }
60
+ return candidates;
61
+ };
62
+ /**
63
+ * Sorts timestamps chronologically and returns the first one where the
64
+ * schedule is unavailable, or undefined if all are available.
65
+ */
66
+ const findFirstUnavailableTimestamp = (schedule, candidates) => {
67
+ candidates.sort((a, b) => (isBeforeTimestamp(a, b) ? -1 : 1));
68
+ for (const candidate of candidates) {
69
+ if (!isScheduleAvailable(schedule, candidate)) {
70
+ return candidate;
71
+ }
72
+ }
73
+ return undefined;
74
+ };
4
75
  /**
5
76
  * Finds the next unavailable timestamp in a schedule starting from the
6
77
  * specified timestamp.
7
78
  *
8
79
  * This function searches forward from the given timestamp to find when the
9
80
  * schedule next becomes unavailable. It handles:
81
+ * - Always-available schedules (`weekly: true`) by searching for overrides
10
82
  * - Same-day unavailability (gaps between time ranges or after the last range)
11
83
  * - Cross-midnight ranges (unavailability after a range that crosses midnight)
12
84
  * - Date overrides (temporary closures)
13
85
  *
14
86
  * The algorithm works by:
15
87
  * 1. Checking if fromTimestamp is already unavailable
16
- * 2. Collecting all potential "end of availability" candidates from:
88
+ * 2. If `weekly` is `true`, searching forward day-by-day for an override that
89
+ * closes availability
90
+ * 3. Otherwise, collecting all potential "end of availability" candidates from:
17
91
  * - Previous day's cross-midnight spillover (ends today at `to` time)
18
92
  * - Current day's regular ranges (end today at `to` time)
19
93
  * - Current day's cross-midnight ranges (end tomorrow at `to` time)
20
- * 3. Sorting candidates and returning the earliest one that's unavailable
94
+ * 4. Sorting candidates and returning the earliest one that's unavailable
21
95
  *
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).
96
+ * Note: For rule-based schedules, no day-by-day iteration is needed because if
97
+ * available, the current range ends within at most ~48 hours (cross-midnight).
25
98
  *
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
99
+ * @param schedule The schedule to check availability against.
100
+ * @param timeZone IANA time zone identifier for timestamp arithmetic.
101
+ * @param fromTimestamp The starting timestamp to search from.
102
+ * @param maxDaysToSearch Maximum number of days to search forward.
103
+ * @returns The next unavailable timestamp, or undefined if no unavailability
104
+ * is found within the search window.
29
105
  *
30
106
  * @example
31
107
  * // Schedule: Mon-Fri 09:00-17:00
@@ -41,8 +117,13 @@ import { isScheduleAvailable } from './isScheduleAvailable.js';
41
117
  * // Schedule: Thu-Sat 20:00-02:00 (cross-midnight)
42
118
  * // Query: Thursday at 23:00
43
119
  * // Returns: Friday at 02:01 (after shift ends)
120
+ *
121
+ * @example
122
+ * // Schedule: weekly: true, override closing Dec 25
123
+ * // Query: Dec 20 at 10:00
124
+ * // Returns: Dec 25 at 00:00
44
125
  */
45
- export const getNextUnavailableFromSchedule = (schedule, fromTimestamp) => {
126
+ export const getNextUnavailableFromSchedule = (schedule, timeZone, fromTimestamp, maxDaysToSearch) => {
46
127
  const initialTimestamp = sTimestamp(fromTimestamp);
47
128
  // Check if already unavailable at fromTimestamp
48
129
  if (!isScheduleAvailable(schedule, initialTimestamp)) {
@@ -50,63 +131,39 @@ export const getNextUnavailableFromSchedule = (schedule, fromTimestamp) => {
50
131
  }
51
132
  const currentDate = getDateFromTimestamp(initialTimestamp);
52
133
  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;
134
+ // When weekly is true (always available), search forward for an override
135
+ // that closes availability.
136
+ const initialResult = getApplicableRuleForDate(schedule, currentDate.date);
137
+ if (initialResult.rules === true) {
138
+ // rules === true implies source === 'weekly', so skip current date
139
+ // and start searching from the next day.
140
+ let searchDate = addDaysToDate(currentDate, 1);
141
+ for (let day = 1; day < maxDaysToSearch; day++) {
142
+ const result = getApplicableRuleForDate(schedule, searchDate.date);
143
+ // If we are here it means that weekly is true, so we need to search for
144
+ // an override that closes availability.
145
+ if (result.source === 'override') {
146
+ const dayStart = getTimestampFromDateAndTime(searchDate, '00:00');
147
+ const nextUnavailable = findFirstUnavailableTimestamp(schedule, [
148
+ dayStart,
149
+ ...collectRangeEndCandidates(result.rules, searchDate, timeZone),
150
+ ]);
151
+ if (nextUnavailable) {
152
+ return nextUnavailable;
97
153
  }
98
- const rangeEnd = getTimestampFromDateAndTime(currentDate, timeRange.to);
99
- const afterRangeEnd = addMinutesToTimestamp(rangeEnd, 1, schedule.timezone);
100
- candidates.push(afterRangeEnd);
101
154
  }
155
+ searchDate = addDaysToDate(searchDate, 1);
102
156
  }
157
+ return undefined;
103
158
  }
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
- }
159
+ // Collect candidates from previous day's cross-midnight spillover and
160
+ // current day's ranges, then return the earliest unavailable one.
161
+ const { rules } = getApplicableRuleForDate(schedule, currentDate.date);
162
+ if (rules === true) {
163
+ return undefined;
110
164
  }
111
- return undefined;
165
+ return findFirstUnavailableTimestamp(schedule, [
166
+ ...collectSpilloverCandidates(schedule, currentDate, timeZone, fromTime),
167
+ ...collectRangeEndCandidates(rules, currentDate, timeZone, fromTime),
168
+ ]);
112
169
  };
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,4 +1,4 @@
1
- import type { TimeRange } from '../types.js';
1
+ import type { TimeRange } from './types.js';
2
2
  /**
3
3
  * Checks if two time ranges overlap when both start on the same day.
4
4
  *
@@ -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
  };
@@ -1,5 +1,6 @@
1
1
  import { Weekday } from 'scdate';
2
- import type { TimeRange, WeeklyScheduleRule } from '../types.js';
2
+ import type { WeeklyScheduleRule } from '../types.js';
3
+ import type { TimeRange } from './types.js';
3
4
  /**
4
5
  * Gets the effective time ranges that apply to a specific weekday for a given
5
6
  * rule, accounting for both direct ranges and cross-midnight spillover from
@@ -14,13 +14,10 @@ export const getEffectiveTimesForWeekday = (rule, weekday) => {
14
14
  const previousWeekday = getPreviousWeekday(weekday);
15
15
  // Check if this weekday is directly included in the rule's weekdays
16
16
  if (doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
17
- // Add all time ranges for this weekday
18
- rule.times.forEach((timeRange) => {
19
- const splitRanges = splitCrossMidnightTimeRange(timeRange);
20
- if (splitRanges.length > 0 && splitRanges[0]) {
21
- effectiveTimes.push(splitRanges[0]);
22
- }
23
- });
17
+ const splitRanges = splitCrossMidnightTimeRange(rule);
18
+ if (splitRanges.length > 0 && splitRanges[0]) {
19
+ effectiveTimes.push(splitRanges[0]);
20
+ }
24
21
  }
25
22
  // Check if previous weekday has cross-midnight ranges that spill into this
26
23
  // weekday.
@@ -30,13 +27,11 @@ export const getEffectiveTimesForWeekday = (rule, weekday) => {
30
27
  // only those that occur in the date range. This ensures spillover is only
31
28
  // included from days that actually exist in the override period.
32
29
  if (doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
33
- rule.times.forEach((timeRange) => {
34
- const splitRanges = splitCrossMidnightTimeRange(timeRange);
35
- // If there are 2 ranges, the second one is the spillover to next day
36
- if (splitRanges.length === 2 && splitRanges[1]) {
37
- effectiveTimes.push(splitRanges[1]);
38
- }
39
- });
30
+ const splitRanges = splitCrossMidnightTimeRange(rule);
31
+ // If there are 2 ranges, the second one is the spillover to next day
32
+ if (splitRanges.length === 2 && splitRanges[1]) {
33
+ effectiveTimes.push(splitRanges[1]);
34
+ }
40
35
  }
41
36
  return effectiveTimes;
42
37
  };
@@ -1,19 +1,16 @@
1
+ export type * from './types.js';
1
2
  export * from './doOverridesOverlap.js';
2
3
  export * from './doRulesOverlap.js';
3
4
  export * from './doTimeRangesOverlap.js';
4
5
  export * from './getApplicableRuleForDate.js';
5
6
  export * from './getEffectiveTimesForWeekday.js';
6
7
  export * from './isTimeInTimeRange.js';
7
- export * from './isValidTimezone.js';
8
8
  export * from './normalizeScheduleForValidation.js';
9
9
  export * from './splitCrossMidnightTimeRange.js';
10
10
  export * from './validateNoEmptyWeekdays.js';
11
11
  export * from './validateNoOverlappingOverrides.js';
12
12
  export * from './validateNoOverlappingRules.js';
13
- export * from './validateNoOverlappingTimesInRule.js';
14
13
  export * from './validateNoSpilloverConflictsAtOverrideBoundaries.js';
15
- export * from './validateNonEmptyTimes.js';
16
14
  export * from './validateOverrideDateOrder.js';
17
15
  export * from './validateOverrideWeekdaysMatchDates.js';
18
16
  export * from './validateScDateFormats.js';
19
- export * from './validateTimezone.js';
@@ -5,17 +5,13 @@ 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';
13
12
  export * from './validateNoOverlappingOverrides.js';
14
13
  export * from './validateNoOverlappingRules.js';
15
- export * from './validateNoOverlappingTimesInRule.js';
16
14
  export * from './validateNoSpilloverConflictsAtOverrideBoundaries.js';
17
- export * from './validateNonEmptyTimes.js';
18
15
  export * from './validateOverrideDateOrder.js';
19
16
  export * from './validateOverrideWeekdaysMatchDates.js';
20
17
  export * from './validateScDateFormats.js';
21
- export * from './validateTimezone.js';
@@ -1,5 +1,6 @@
1
1
  import { type STime } from 'scdate';
2
- import type { STimeString, TimeRange } from '../types.js';
2
+ import type { STimeString } from '../types.js';
3
+ import type { TimeRange } from './types.js';
3
4
  /**
4
5
  * Checks if a time falls within a time range, with support for
5
6
  * cross-midnight ranges (next-day portion).
@@ -1,4 +1,4 @@
1
- import type { TimeRange } from '../types.js';
1
+ import type { TimeRange } from './types.js';
2
2
  /**
3
3
  * Splits a time range that crosses midnight into two same-day ranges.
4
4
  */
@@ -5,8 +5,9 @@ import { isAfterTime, sTime } from 'scdate';
5
5
  export const splitCrossMidnightTimeRange = (timeRange) => {
6
6
  // Check if range crosses midnight
7
7
  if (isAfterTime(timeRange.to, timeRange.from)) {
8
- // Same day range
9
- return [timeRange];
8
+ // Same day range — construct a new object to strip extra fields (e.g.,
9
+ // weekdays) when callers pass a WeeklyScheduleRule as a TimeRange.
10
+ return [{ from: timeRange.from, to: timeRange.to }];
10
11
  }
11
12
  // Cross-midnight range: split into two periods
12
13
  return [
@@ -0,0 +1,13 @@
1
+ import type { STime } from 'scdate';
2
+ import type { STimeString } from '../types.js';
3
+ /**
4
+ * Represents a time range within a day. Time ranges can cross midnight. For
5
+ * example, a range from 20:00 to 02:00 represents 8:00 PM to 2:00 AM the next
6
+ * day.
7
+ */
8
+ export interface TimeRange {
9
+ /** Start time of the range (inclusive) */
10
+ from: STime | STimeString;
11
+ /** End time of the range (inclusive) */
12
+ to: STime | STimeString;
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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({