scschedule 3.0.0 → 3.2.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/README.md CHANGED
@@ -51,7 +51,8 @@ const restaurant: Schedule = {
51
51
  weekly: [
52
52
  {
53
53
  weekdays: sWeekdays('-MTWTFS'), // Mon-Sat
54
- times: [{ from: sTime('11:00'), to: sTime('22:00') }],
54
+ from: sTime('11:00'),
55
+ to: sTime('22:00'),
55
56
  },
56
57
  ],
57
58
  }
@@ -97,10 +98,6 @@ Define recurring availability patterns for specific days of the week:
97
98
  ```typescript
98
99
  interface WeeklyScheduleRule {
99
100
  weekdays: SWeekdays // e.g., 'SMTWTFS' or '-MTWTF-'
100
- times: TimeRange[] // Array of time ranges
101
- }
102
-
103
- interface TimeRange {
104
101
  from: STime // e.g., '09:00'
105
102
  to: STime // e.g., '17:00'
106
103
  }
@@ -278,7 +275,8 @@ const restaurant: Schedule = {
278
275
  weekly: [
279
276
  {
280
277
  weekdays: sWeekdays('-MTWTFS'), // Mon-Sat
281
- times: [{ from: sTime('11:00'), to: sTime('22:00') }],
278
+ from: sTime('11:00'),
279
+ to: sTime('22:00'),
282
280
  },
283
281
  ],
284
282
  }
@@ -291,10 +289,13 @@ const restaurant: Schedule = {
291
289
  weekly: [
292
290
  {
293
291
  weekdays: sWeekdays('-MTWTFS'), // Mon-Sat
294
- times: [
295
- { from: sTime('11:00'), to: sTime('14:00') }, // Lunch
296
- { from: sTime('17:00'), to: sTime('22:00') }, // Dinner
297
- ],
292
+ from: sTime('11:00'),
293
+ to: sTime('14:00'), // Lunch
294
+ },
295
+ {
296
+ weekdays: sWeekdays('-MTWTFS'), // Mon-Sat
297
+ from: sTime('17:00'),
298
+ to: sTime('22:00'), // Dinner
298
299
  },
299
300
  ],
300
301
  }
@@ -308,12 +309,14 @@ const restaurant: Schedule = {
308
309
  {
309
310
  // Weekdays: longer hours
310
311
  weekdays: sWeekdays('-MTWTF-'), // Mon-Fri
311
- times: [{ from: sTime('10:00'), to: sTime('23:00') }],
312
+ from: sTime('10:00'),
313
+ to: sTime('23:00'),
312
314
  },
313
315
  {
314
316
  // Weekends: shorter hours
315
317
  weekdays: sWeekdays('S-----S'), // Sat-Sun
316
- times: [{ from: sTime('12:00'), to: sTime('20:00') }],
318
+ from: sTime('12:00'),
319
+ to: sTime('20:00'),
317
320
  },
318
321
  ],
319
322
  }
@@ -348,7 +351,8 @@ const withExtendedHours: Schedule = {
348
351
  {
349
352
  // Extended hours for December, weekends only
350
353
  weekdays: sWeekdays('S-----S'),
351
- times: [{ from: sTime('08:00'), to: sTime('23:00') }],
354
+ from: sTime('08:00'),
355
+ to: sTime('23:00'),
352
356
  },
353
357
  ],
354
358
  },
@@ -369,7 +373,8 @@ const newSchedule: Schedule = {
369
373
  rules: [
370
374
  {
371
375
  weekdays: sWeekdays('SMTWTFS'), // All days
372
- times: [{ from: sTime('09:00'), to: sTime('21:00') }],
376
+ from: sTime('09:00'),
377
+ to: sTime('21:00'),
373
378
  },
374
379
  ],
375
380
  },
@@ -384,7 +389,8 @@ const lateNightBar: Schedule = {
384
389
  weekly: [
385
390
  {
386
391
  weekdays: sWeekdays('----TFS'), // Thu-Sat
387
- times: [{ from: sTime('20:00'), to: sTime('03:00') }], // 8PM-3AM
392
+ from: sTime('20:00'),
393
+ to: sTime('03:00'), // 8PM-3AM
388
394
  },
389
395
  ],
390
396
  }
@@ -429,7 +435,8 @@ const popUpShop: Schedule = {
429
435
  rules: [
430
436
  {
431
437
  weekdays: sWeekdays('SMTWTFS'),
432
- times: [{ from: sTime('10:00'), to: sTime('18:00') }],
438
+ from: sTime('10:00'),
439
+ to: sTime('18:00'),
433
440
  },
434
441
  ],
435
442
  },
@@ -446,7 +453,8 @@ const businessHours: Schedule = {
446
453
  weekly: [
447
454
  {
448
455
  weekdays: sWeekdays('-MTWTFS'),
449
- times: [{ from: sTime('11:00'), to: sTime('22:00') }],
456
+ from: sTime('11:00'),
457
+ to: sTime('22:00'),
450
458
  },
451
459
  ],
452
460
  }
@@ -488,17 +496,6 @@ type ValidationError =
488
496
  issue: ValidationIssue.OverlappingSpecificOverrides
489
497
  overrideIndexes: [number, number]
490
498
  }
491
- | {
492
- issue: ValidationIssue.OverlappingTimesInRule
493
- location:
494
- | { type: RuleLocationType.Weekly; ruleIndex: number }
495
- | {
496
- type: RuleLocationType.Override
497
- overrideIndex: number
498
- ruleIndex: number
499
- }
500
- timeRangeIndexes: [number, number]
501
- }
502
499
  | {
503
500
  issue: ValidationIssue.OverlappingRulesInWeekly
504
501
  ruleIndexes: [number, number]
@@ -510,16 +507,6 @@ type ValidationError =
510
507
  ruleIndexes: [number, number]
511
508
  weekday: Weekday
512
509
  }
513
- | {
514
- issue: ValidationIssue.EmptyTimes
515
- location:
516
- | { type: RuleLocationType.Weekly; ruleIndex: number }
517
- | {
518
- type: RuleLocationType.Override
519
- overrideIndex: number
520
- ruleIndex: number
521
- }
522
- }
523
510
  | {
524
511
  issue: ValidationIssue.EmptyWeekdays
525
512
  location:
@@ -590,7 +577,6 @@ import type {
590
577
  Schedule,
591
578
  WeeklyScheduleRule,
592
579
  OverrideScheduleRule,
593
- TimeRange,
594
580
  AvailabilityRange,
595
581
  ValidationError,
596
582
  ValidationResult,
@@ -8,13 +8,6 @@ export declare enum ValidationIssue {
8
8
  DuplicateOverrides = "duplicate-overrides",
9
9
  /** Two or more specific overrides have overlapping date ranges */
10
10
  OverlappingSpecificOverrides = "overlapping-specific-overrides",
11
- /** Time ranges within a single rule overlap with each other */
12
- OverlappingTimesInRule = "overlapping-times-in-rule",
13
- /**
14
- * A rule has an empty times array (should have at least one time range or be
15
- * removed)
16
- */
17
- EmptyTimes = "empty-times",
18
11
  /**
19
12
  * A field contains an invalid scdate format (SDate, STime, SWeekdays, or
20
13
  * STimestamp)
package/dist/constants.js CHANGED
@@ -9,13 +9,6 @@ export var ValidationIssue;
9
9
  ValidationIssue["DuplicateOverrides"] = "duplicate-overrides";
10
10
  /** Two or more specific overrides have overlapping date ranges */
11
11
  ValidationIssue["OverlappingSpecificOverrides"] = "overlapping-specific-overrides";
12
- /** Time ranges within a single rule overlap with each other */
13
- ValidationIssue["OverlappingTimesInRule"] = "overlapping-times-in-rule";
14
- /**
15
- * A rule has an empty times array (should have at least one time range or be
16
- * removed)
17
- */
18
- ValidationIssue["EmptyTimes"] = "empty-times";
19
12
  /**
20
13
  * A field contains an invalid scdate format (SDate, STime, SWeekdays, or
21
14
  * STimestamp)
@@ -36,19 +36,16 @@ export const getAvailableRangesFromSchedule = (schedule, startDate, endDate) =>
36
36
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
37
37
  continue;
38
38
  }
39
- // Process each time range
40
- for (const timeRange of rule.times) {
41
- // Handle cross-midnight ranges (from > to means it crosses midnight)
42
- const isCrossMidnight = isAfterTime(timeRange.from, timeRange.to);
43
- const rangeStart = getTimestampFromDateAndTime(currentDate, timeRange.from);
44
- const rangeEnd = isCrossMidnight
45
- ? getTimestampFromDateAndTime(addDaysToDate(currentDate, 1), timeRange.to)
46
- : getTimestampFromDateAndTime(currentDate, timeRange.to);
47
- ranges.push({
48
- from: rangeStart,
49
- to: rangeEnd,
50
- });
51
- }
39
+ // Handle cross-midnight ranges (from > to means it crosses midnight)
40
+ const isCrossMidnight = isAfterTime(rule.from, rule.to);
41
+ const rangeStart = getTimestampFromDateAndTime(currentDate, rule.from);
42
+ const rangeEnd = isCrossMidnight
43
+ ? getTimestampFromDateAndTime(addDaysToDate(currentDate, 1), rule.to)
44
+ : getTimestampFromDateAndTime(currentDate, rule.to);
45
+ ranges.push({
46
+ from: rangeStart,
47
+ to: rangeEnd,
48
+ });
52
49
  }
53
50
  // Move to the next day
54
51
  currentDate = addDaysToDate(currentDate, 1);
@@ -77,15 +77,13 @@ export const getNextAvailableFromSchedule = (schedule, fromTimestamp, maxDaysToS
77
77
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
78
78
  continue;
79
79
  }
80
- for (const timeRange of rule.times) {
81
- // Day 0: only consider ranges starting after fromTimestamp's time
82
- // Day 1+: consider all ranges (any start time qualifies)
83
- if (day === 0 && !isAfterTime(timeRange.from, fromTime)) {
84
- continue;
85
- }
86
- if (!earliestTime || isBeforeTime(timeRange.from, earliestTime)) {
87
- earliestTime = timeRange.from;
88
- }
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;
89
87
  }
90
88
  }
91
89
  if (earliestTime) {
@@ -14,20 +14,18 @@ const collectRangeEndCandidates = (rules, date, timeZone, afterTime) => {
14
14
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
15
15
  continue;
16
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));
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;
30
26
  }
27
+ const rangeEnd = getTimestampFromDateAndTime(date, rule.to);
28
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
31
29
  }
32
30
  }
33
31
  return candidates;
@@ -49,17 +47,15 @@ const collectSpilloverCandidates = (schedule, currentDate, timeZone, afterTime)
49
47
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
50
48
  continue;
51
49
  }
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));
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;
62
56
  }
57
+ const rangeEnd = getTimestampFromDateAndTime(currentDate, rule.to);
58
+ candidates.push(addMinutesToTimestamp(rangeEnd, 1, timeZone));
63
59
  }
64
60
  return candidates;
65
61
  };
@@ -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,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,3 +1,4 @@
1
+ export type * from './types.js';
1
2
  export * from './doOverridesOverlap.js';
2
3
  export * from './doRulesOverlap.js';
3
4
  export * from './doTimeRangesOverlap.js';
@@ -7,10 +8,8 @@ export * from './isTimeInTimeRange.js';
7
8
  export * from './normalizeScheduleForValidation.js';
8
9
  export * from './splitCrossMidnightTimeRange.js';
9
10
  export * from './validateNoEmptyWeekdays.js';
10
- export * from './validateNonEmptyTimes.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
14
  export * from './validateOverrideDateOrder.js';
16
15
  export * from './validateOverrideWeekdaysMatchDates.js';
@@ -9,10 +9,8 @@ export * from './normalizeScheduleForValidation.js';
9
9
  export * from './splitCrossMidnightTimeRange.js';
10
10
  // Internal validation helpers
11
11
  export * from './validateNoEmptyWeekdays.js';
12
- export * from './validateNonEmptyTimes.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
15
  export * from './validateOverrideDateOrder.js';
18
16
  export * from './validateOverrideWeekdaysMatchDates.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 {};
@@ -38,52 +38,47 @@ export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
38
38
  if (!doesWeekdaysIncludeWeekday(previousDayRule.weekdays, previousWeekday)) {
39
39
  return;
40
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;
41
+ // Check for cross-midnight
42
+ const splitRanges = splitCrossMidnightTimeRange(previousDayRule);
43
+ // If cross-midnight, splitRanges[1] is the spillover portion
44
+ if (splitRanges.length === 2 && splitRanges[1]) {
45
+ const spilloverRange = splitRanges[1];
46
+ // Check if spillover conflicts with override first day's times
47
+ override.rules.forEach((overrideRule, overrideRuleIndex) => {
48
+ // Does first day match override rule's weekdays?
49
+ if (!doesWeekdaysIncludeWeekday(overrideRule.weekdays, firstDateWeekday)) {
50
+ return;
51
+ }
52
+ const overrideSplitRanges = splitCrossMidnightTimeRange(overrideRule);
53
+ // Check same-day portion of override time range
54
+ const overrideSameDayRange = overrideSplitRanges[0];
55
+ if (overrideSameDayRange &&
56
+ doTimeRangesOverlap(spilloverRange, overrideSameDayRange)) {
57
+ // Use source information from getApplicableRuleForDate
58
+ if (previousDayResult.source === 'override') {
59
+ // Previous day is in an override
60
+ errors.push({
61
+ issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
62
+ overrideIndex,
63
+ date: firstDate.toJSON(),
64
+ overrideRuleIndex,
65
+ sourceOverrideIndex: previousDayResult.overrideIndex,
66
+ sourceOverrideRuleIndex: previousDayRuleIndex,
67
+ });
52
68
  }
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
- });
84
- });
85
- }
86
- });
69
+ else {
70
+ // Previous day is weekly
71
+ errors.push({
72
+ issue: ValidationIssue.SpilloverConflictIntoOverrideFirstDay,
73
+ overrideIndex,
74
+ date: firstDate.toJSON(),
75
+ overrideRuleIndex,
76
+ sourceWeeklyRuleIndex: previousDayRuleIndex,
77
+ });
78
+ }
79
+ }
80
+ });
81
+ }
87
82
  });
88
83
  }
89
84
  // PART 2: Check spillover FROM override last day into next day
@@ -101,65 +96,60 @@ export const validateNoSpilloverConflictsAtOverrideBoundaries = (schedule) => {
101
96
  if (!doesWeekdaysIncludeWeekday(overrideRule.weekdays, lastDateWeekday)) {
102
97
  return;
103
98
  }
104
- // Check each time range for cross-midnight
105
- overrideRule.times.forEach((timeRange) => {
106
- const splitRanges = splitCrossMidnightTimeRange(timeRange);
107
- // If cross-midnight, splitRanges[1] is the spillover portion
108
- if (splitRanges.length === 2 && splitRanges[1]) {
109
- const spilloverRange = splitRanges[1];
110
- // Get what rule applies to next day
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
- });
99
+ // Check for cross-midnight
100
+ const splitRanges = splitCrossMidnightTimeRange(overrideRule);
101
+ // If cross-midnight, splitRanges[1] is the spillover portion
102
+ if (splitRanges.length === 2 && splitRanges[1]) {
103
+ const spilloverRange = splitRanges[1];
104
+ // Get what rule applies to next day
105
+ const nextDayResult = getApplicableRuleForDate(schedule, nextDate);
106
+ // If next day is always available (weekly: true), spillover
107
+ // creates overlapping ranges with the full-day availability.
108
+ if (nextDayResult.rules === true) {
109
+ errors.push({
110
+ issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
111
+ overrideIndex,
112
+ date: lastDate.toJSON(),
113
+ overrideRuleIndex,
114
+ });
115
+ return;
116
+ }
117
+ // Check if spillover conflicts with next day's times
118
+ nextDayResult.rules.forEach((nextDayRule, nextDayRuleIndex) => {
119
+ // Does next day match the rule's weekdays?
120
+ if (!doesWeekdaysIncludeWeekday(nextDayRule.weekdays, nextDateWeekday)) {
121
121
  return;
122
122
  }
123
- // Check if spillover conflicts with next day's times
124
- nextDayResult.rules.forEach((nextDayRule, nextDayRuleIndex) => {
125
- // Does next day match the rule's weekdays?
126
- if (!doesWeekdaysIncludeWeekday(nextDayRule.weekdays, nextDateWeekday)) {
127
- return;
123
+ const nextDaySplitRanges = splitCrossMidnightTimeRange(nextDayRule);
124
+ // Check same-day portion of next day's time range
125
+ const nextDaySameDayRange = nextDaySplitRanges[0];
126
+ if (nextDaySameDayRange &&
127
+ doTimeRangesOverlap(spilloverRange, nextDaySameDayRange)) {
128
+ // Use source information from getApplicableRuleForDate
129
+ if (nextDayResult.source === 'override') {
130
+ // Next day is in an override
131
+ errors.push({
132
+ issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
133
+ overrideIndex,
134
+ date: lastDate.toJSON(),
135
+ overrideRuleIndex,
136
+ nextDayOverrideIndex: nextDayResult.overrideIndex,
137
+ nextDayOverrideRuleIndex: nextDayRuleIndex,
138
+ });
128
139
  }
129
- // Check each time range in next day's rule
130
- nextDayRule.times.forEach((nextDayTimeRange) => {
131
- const nextDaySplitRanges = splitCrossMidnightTimeRange(nextDayTimeRange);
132
- // Check same-day portion of next day's time range
133
- const nextDaySameDayRange = nextDaySplitRanges[0];
134
- if (nextDaySameDayRange &&
135
- doTimeRangesOverlap(spilloverRange, nextDaySameDayRange)) {
136
- // Use source information from getApplicableRuleForDate
137
- if (nextDayResult.source === 'override') {
138
- // Next day is in an override
139
- errors.push({
140
- issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
141
- overrideIndex,
142
- date: lastDate.toJSON(),
143
- overrideRuleIndex,
144
- nextDayOverrideIndex: nextDayResult.overrideIndex,
145
- nextDayOverrideRuleIndex: nextDayRuleIndex,
146
- });
147
- }
148
- else {
149
- // Next day is weekly
150
- errors.push({
151
- issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
152
- overrideIndex,
153
- date: lastDate.toJSON(),
154
- overrideRuleIndex,
155
- nextDayWeeklyRuleIndex: nextDayRuleIndex,
156
- });
157
- }
158
- }
159
- });
160
- });
161
- }
162
- });
140
+ else {
141
+ // Next day is weekly
142
+ errors.push({
143
+ issue: ValidationIssue.SpilloverConflictOverrideIntoNext,
144
+ overrideIndex,
145
+ date: lastDate.toJSON(),
146
+ overrideRuleIndex,
147
+ nextDayWeeklyRuleIndex: nextDayRuleIndex,
148
+ });
149
+ }
150
+ }
151
+ });
152
+ }
163
153
  });
164
154
  });
165
155
  return errors;
@@ -67,7 +67,7 @@ const validateTimeRange = (timeRange, fieldPrefix) => {
67
67
  return errors;
68
68
  };
69
69
  /**
70
- * Validates a rule (weekdays and all time ranges).
70
+ * Validates a rule (weekdays and time range).
71
71
  */
72
72
  const validateRule = (rule, fieldPrefix) => {
73
73
  const errors = [];
@@ -75,9 +75,7 @@ const validateRule = (rule, fieldPrefix) => {
75
75
  if (weekdaysError) {
76
76
  errors.push(weekdaysError);
77
77
  }
78
- rule.times.forEach((timeRange, timeIndex) => {
79
- errors.push(...validateTimeRange(timeRange, `${fieldPrefix}.times[${String(timeIndex)}]`));
80
- });
78
+ errors.push(...validateTimeRange(rule, fieldPrefix));
81
79
  return errors;
82
80
  };
83
81
  /**
@@ -29,8 +29,8 @@ export const isScheduleAvailable = (schedule, timestamp) => {
29
29
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, weekday)) {
30
30
  return false;
31
31
  }
32
- // Check if any time range matches
33
- return rule.times.some((timeRange) => isTimeInTimeRange(time, timeRange, true));
32
+ // Check if time range matches
33
+ return isTimeInTimeRange(time, rule, true);
34
34
  });
35
35
  if (matchesSameDay) {
36
36
  return true;
@@ -48,7 +48,7 @@ export const isScheduleAvailable = (schedule, timestamp) => {
48
48
  if (!doesWeekdaysIncludeWeekday(rule.weekdays, previousWeekday)) {
49
49
  return false;
50
50
  }
51
- // Check if any time range matches (next-day portion)
52
- return rule.times.some((timeRange) => isTimeInTimeRange(time, timeRange, false));
51
+ // Check if time range matches (next-day portion)
52
+ return isTimeInTimeRange(time, rule, false);
53
53
  });
54
54
  };
package/dist/types.d.ts CHANGED
@@ -16,29 +16,18 @@ export type STimestampString = string;
16
16
  * String in SMTWTFS format representing weekdays.
17
17
  */
18
18
  export type SWeekdaysString = string;
19
- /**
20
- * Represents a time range within a day. Time ranges can cross midnight. For
21
- * example, a range from 20:00 to 02:00 represents 8:00 PM to 2:00 AM the next
22
- * day.
23
- */
24
- export interface TimeRange {
25
- /** Start time of the range (inclusive) */
26
- from: STime | STimeString;
27
- /** End time of the range (inclusive) */
28
- to: STime | STimeString;
29
- }
30
19
  /**
31
20
  * Defines a recurring weekly availability pattern. Specifies which days of the
32
- * week are available and what time ranges apply on those days.
21
+ * week are available and what time range applies on those days. For split
22
+ * shifts, use multiple rules with the same weekdays.
33
23
  */
34
24
  export interface WeeklyScheduleRule {
35
25
  /** Days of the week this rule applies to */
36
26
  weekdays: SWeekdays | SWeekdaysString;
37
- /**
38
- * Time ranges when available on the specified weekdays. Empty array means
39
- * unavailable on these days.
40
- */
41
- times: TimeRange[];
27
+ /** Start time of the range (inclusive). Ranges can cross midnight. */
28
+ from: STime | STimeString;
29
+ /** End time of the range (inclusive). Ranges can cross midnight. */
30
+ to: STime | STimeString;
42
31
  }
43
32
  /**
44
33
  * Defines a date-specific override to the weekly schedule. Overrides apply to
@@ -103,35 +92,6 @@ export type ValidationError = {
103
92
  issue: ValidationIssue.OverlappingSpecificOverrides;
104
93
  /** Indexes of the two overlapping overrides */
105
94
  overrideIndexes: [number, number];
106
- } | {
107
- /** Time ranges within a single rule overlap with each other */
108
- issue: ValidationIssue.OverlappingTimesInRule;
109
- /** Location of the rule containing overlapping times */
110
- location: {
111
- type: RuleLocationType.Weekly;
112
- ruleIndex: number;
113
- } | {
114
- type: RuleLocationType.Override;
115
- overrideIndex: number;
116
- ruleIndex: number;
117
- };
118
- /** Indexes of the two overlapping time ranges within the rule */
119
- timeRangeIndexes: [number, number];
120
- } | {
121
- /**
122
- * A rule has an empty times array
123
- * (should have at least one time range or be removed)
124
- */
125
- issue: ValidationIssue.EmptyTimes;
126
- /** Location of the rule with empty times */
127
- location: {
128
- type: RuleLocationType.Weekly;
129
- ruleIndex: number;
130
- } | {
131
- type: RuleLocationType.Override;
132
- overrideIndex: number;
133
- ruleIndex: number;
134
- };
135
95
  } | {
136
96
  /**
137
97
  * A field contains an invalid scdate format
@@ -3,8 +3,8 @@ import type { Schedule, ValidationResult } from './types.js';
3
3
  * Validates a schedule configuration and returns all validation errors found.
4
4
  *
5
5
  * Validation is performed in two phases:
6
- * 1. Structural validation (formats, date order, empty weekdays, non-empty
7
- * times, weekday-date mismatch) - runs on original schedule
6
+ * 1. Structural validation (formats, date order, empty weekdays,
7
+ * weekday-date mismatch) - runs on original schedule
8
8
  * 2. Semantic validation (overlaps, conflicts) - runs on normalized schedule
9
9
  * after filtering weekdays to actual dates
10
10
  *
@@ -2,9 +2,7 @@ import { normalizeScheduleForValidation } from './internal/normalizeScheduleForV
2
2
  import { validateNoEmptyWeekdays } from './internal/validateNoEmptyWeekdays.js';
3
3
  import { validateNoOverlappingOverrides } from './internal/validateNoOverlappingOverrides.js';
4
4
  import { validateNoOverlappingRules } from './internal/validateNoOverlappingRules.js';
5
- import { validateNoOverlappingTimesInRule } from './internal/validateNoOverlappingTimesInRule.js';
6
5
  import { validateNoSpilloverConflictsAtOverrideBoundaries } from './internal/validateNoSpilloverConflictsAtOverrideBoundaries.js';
7
- import { validateNonEmptyTimes } from './internal/validateNonEmptyTimes.js';
8
6
  import { validateOverrideDateOrder } from './internal/validateOverrideDateOrder.js';
9
7
  import { validateOverrideWeekdaysMatchDates } from './internal/validateOverrideWeekdaysMatchDates.js';
10
8
  import { validateScDateFormats } from './internal/validateScDateFormats.js';
@@ -12,8 +10,8 @@ import { validateScDateFormats } from './internal/validateScDateFormats.js';
12
10
  * Validates a schedule configuration and returns all validation errors found.
13
11
  *
14
12
  * Validation is performed in two phases:
15
- * 1. Structural validation (formats, date order, empty weekdays, non-empty
16
- * times, weekday-date mismatch) - runs on original schedule
13
+ * 1. Structural validation (formats, date order, empty weekdays,
14
+ * weekday-date mismatch) - runs on original schedule
17
15
  * 2. Semantic validation (overlaps, conflicts) - runs on normalized schedule
18
16
  * after filtering weekdays to actual dates
19
17
  *
@@ -31,7 +29,6 @@ export const validateSchedule = (schedule) => {
31
29
  ...validateScDateFormats(schedule),
32
30
  ...validateOverrideDateOrder(schedule),
33
31
  ...validateNoEmptyWeekdays(schedule),
34
- ...validateNonEmptyTimes(schedule),
35
32
  ...validateOverrideWeekdaysMatchDates(schedule),
36
33
  ];
37
34
  // Return immediately if structural errors exist
@@ -46,7 +43,6 @@ export const validateSchedule = (schedule) => {
46
43
  // Phase 3: Semantic validation on normalized schedule
47
44
  const semanticErrors = [
48
45
  ...validateNoOverlappingOverrides(normalizedSchedule),
49
- ...validateNoOverlappingTimesInRule(normalizedSchedule),
50
46
  ...validateNoOverlappingRules(normalizedSchedule),
51
47
  ...validateNoSpilloverConflictsAtOverrideBoundaries(normalizedSchedule),
52
48
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scschedule",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js"
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "scdate": "3.0.0"
23
+ "scdate": "3.2.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@eslint/js": "^9.39.2",
@@ -1,5 +0,0 @@
1
- import type { Schedule, ValidationError } from '../types.js';
2
- /**
3
- * Validates that time ranges within rules do not overlap with each other.
4
- */
5
- export declare const validateNoOverlappingTimesInRule: (schedule: Schedule) => ValidationError[];
@@ -1,55 +0,0 @@
1
- import { RuleLocationType, ValidationIssue } from '../constants.js';
2
- import { doTimeRangesOverlap } from './doTimeRangesOverlap.js';
3
- /**
4
- * Validates that time ranges within a single rule do not overlap.
5
- */
6
- const validateRuleTimes = (rule, location) => {
7
- const errors = [];
8
- if (rule.times.length < 2) {
9
- return errors;
10
- }
11
- // Check all pairs of time ranges for overlap on the same weekday
12
- for (let i = 0; i < rule.times.length; i++) {
13
- for (let j = i + 1; j < rule.times.length; j++) {
14
- const timeRange1 = rule.times[i];
15
- const timeRange2 = rule.times[j];
16
- if (timeRange1 &&
17
- timeRange2 &&
18
- doTimeRangesOverlap(timeRange1, timeRange2)) {
19
- errors.push({
20
- issue: ValidationIssue.OverlappingTimesInRule,
21
- location,
22
- timeRangeIndexes: [i, j],
23
- });
24
- }
25
- }
26
- }
27
- return errors;
28
- };
29
- /**
30
- * Validates that time ranges within rules do not overlap with each other.
31
- */
32
- export const validateNoOverlappingTimesInRule = (schedule) => {
33
- const errors = [];
34
- // Validate weekly rules
35
- const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
36
- weeklyRules.forEach((rule, ruleIndex) => {
37
- const ruleErrors = validateRuleTimes(rule, {
38
- type: RuleLocationType.Weekly,
39
- ruleIndex,
40
- });
41
- errors.push(...ruleErrors);
42
- });
43
- // Validate override rules
44
- schedule.overrides?.forEach((override, overrideIndex) => {
45
- override.rules.forEach((rule, ruleIndex) => {
46
- const ruleErrors = validateRuleTimes(rule, {
47
- type: RuleLocationType.Override,
48
- overrideIndex,
49
- ruleIndex,
50
- });
51
- errors.push(...ruleErrors);
52
- });
53
- });
54
- return errors;
55
- };
@@ -1,5 +0,0 @@
1
- import type { Schedule, ValidationError } from '../types.js';
2
- /**
3
- * Validates that all rules have at least one time range defined.
4
- */
5
- export declare const validateNonEmptyTimes: (schedule: Schedule) => ValidationError[];
@@ -1,36 +0,0 @@
1
- import { RuleLocationType, ValidationIssue } from '../constants.js';
2
- /**
3
- * Validates that all rules have at least one time range defined.
4
- */
5
- export const validateNonEmptyTimes = (schedule) => {
6
- const errors = [];
7
- // Validate weekly rules
8
- const weeklyRules = schedule.weekly === true ? [] : schedule.weekly;
9
- weeklyRules.forEach((rule, ruleIndex) => {
10
- if (rule.times.length === 0) {
11
- errors.push({
12
- issue: ValidationIssue.EmptyTimes,
13
- location: {
14
- type: RuleLocationType.Weekly,
15
- ruleIndex,
16
- },
17
- });
18
- }
19
- });
20
- // Validate override rules
21
- schedule.overrides?.forEach((override, overrideIndex) => {
22
- override.rules.forEach((rule, ruleIndex) => {
23
- if (rule.times.length === 0) {
24
- errors.push({
25
- issue: ValidationIssue.EmptyTimes,
26
- location: {
27
- type: RuleLocationType.Override,
28
- overrideIndex,
29
- ruleIndex,
30
- },
31
- });
32
- }
33
- });
34
- });
35
- return errors;
36
- };