hron-ts 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -141,6 +141,14 @@ function ordinalToN(ord) {
141
141
  };
142
142
  return map[ord];
143
143
  }
144
+ var ALL_WEEKDAYS = [
145
+ "monday",
146
+ "tuesday",
147
+ "wednesday",
148
+ "thursday",
149
+ "friday"
150
+ ];
151
+ var ALL_WEEKEND = ["saturday", "sunday"];
144
152
  function newScheduleData(expr) {
145
153
  return {
146
154
  expr,
@@ -286,14 +294,23 @@ function toCron(schedule) {
286
294
  "not expressible as cron (last day of month not supported)"
287
295
  );
288
296
  }
289
- throw HronError.cron(
290
- "not expressible as cron (last weekday of month not supported)"
291
- );
297
+ if (target.type === "lastWeekday") {
298
+ throw HronError.cron(
299
+ "not expressible as cron (last weekday of month not supported)"
300
+ );
301
+ }
302
+ if (target.type === "ordinalWeekday") {
303
+ throw HronError.cron(
304
+ "not expressible as cron (ordinal weekday of month not supported)"
305
+ );
306
+ }
307
+ if (target.direction !== null) {
308
+ throw HronError.cron(
309
+ "not expressible as cron (directional nearest weekday not supported)"
310
+ );
311
+ }
312
+ return `${time.minute} ${time.hour} ${target.day}W * *`;
292
313
  }
293
- case "ordinalRepeat":
294
- throw HronError.cron(
295
- "not expressible as cron (ordinal weekday of month not supported)"
296
- );
297
314
  case "singleDate":
298
315
  throw HronError.cron(
299
316
  "not expressible as cron (single dates are not repeating)"
@@ -320,110 +337,596 @@ function dayFilterToCronDow(filter) {
320
337
  }
321
338
  }
322
339
  function fromCron(cron) {
323
- const fields = cron.trim().split(/\s+/);
340
+ const trimmed = cron.trim();
341
+ if (trimmed.startsWith("@")) {
342
+ return parseCronShortcut(trimmed);
343
+ }
344
+ const fields = trimmed.split(/\s+/);
324
345
  if (fields.length !== 5) {
325
346
  throw HronError.cron(`expected 5 cron fields, got ${fields.length}`);
326
347
  }
327
- const [minuteField, hourField, domField, _monthField, dowField] = fields;
328
- if (minuteField.startsWith("*/")) {
329
- const interval = parseInt(minuteField.slice(2), 10);
330
- if (Number.isNaN(interval)) throw HronError.cron("invalid minute interval");
331
- let fromHour = 0;
332
- let toHour = 23;
348
+ const [minuteField, hourField, domFieldRaw, monthField, dowFieldRaw] = fields;
349
+ const domField = domFieldRaw === "?" ? "*" : domFieldRaw;
350
+ const dowField = dowFieldRaw === "?" ? "*" : dowFieldRaw;
351
+ const during = parseMonthField(monthField);
352
+ const nthWeekdayResult = tryParseNthWeekday(
353
+ minuteField,
354
+ hourField,
355
+ domField,
356
+ dowField,
357
+ during
358
+ );
359
+ if (nthWeekdayResult) return nthWeekdayResult;
360
+ const lastDayResult = tryParseLastDay(
361
+ minuteField,
362
+ hourField,
363
+ domField,
364
+ dowField,
365
+ during
366
+ );
367
+ if (lastDayResult) return lastDayResult;
368
+ if (domField.endsWith("W") && domField !== "LW") {
369
+ const nearestWeekdayResult = tryParseNearestWeekday(
370
+ minuteField,
371
+ hourField,
372
+ domField,
373
+ dowField,
374
+ during
375
+ );
376
+ if (nearestWeekdayResult) return nearestWeekdayResult;
377
+ }
378
+ const intervalResult = tryParseInterval(
379
+ minuteField,
380
+ hourField,
381
+ domField,
382
+ dowField,
383
+ during
384
+ );
385
+ if (intervalResult) return intervalResult;
386
+ const minute = parseSingleValue(minuteField, "minute", 0, 59);
387
+ const hour = parseSingleValue(hourField, "hour", 0, 23);
388
+ const time = { hour, minute };
389
+ if (domField !== "*" && dowField === "*") {
390
+ const target = parseDomField(domField);
391
+ const schedule2 = newScheduleData({
392
+ type: "monthRepeat",
393
+ interval: 1,
394
+ target,
395
+ times: [time]
396
+ });
397
+ schedule2.during = during;
398
+ return schedule2;
399
+ }
400
+ const days = parseCronDow(dowField);
401
+ const schedule = newScheduleData({
402
+ type: "dayRepeat",
403
+ interval: 1,
404
+ days,
405
+ times: [time]
406
+ });
407
+ schedule.during = during;
408
+ return schedule;
409
+ }
410
+ function parseCronShortcut(cron) {
411
+ switch (cron.toLowerCase()) {
412
+ case "@yearly":
413
+ case "@annually":
414
+ return newScheduleData({
415
+ type: "yearRepeat",
416
+ interval: 1,
417
+ target: { type: "date", month: "jan", day: 1 },
418
+ times: [{ hour: 0, minute: 0 }]
419
+ });
420
+ case "@monthly":
421
+ return newScheduleData({
422
+ type: "monthRepeat",
423
+ interval: 1,
424
+ target: { type: "days", specs: [{ type: "single", day: 1 }] },
425
+ times: [{ hour: 0, minute: 0 }]
426
+ });
427
+ case "@weekly":
428
+ return newScheduleData({
429
+ type: "dayRepeat",
430
+ interval: 1,
431
+ days: { type: "days", days: ["sunday"] },
432
+ times: [{ hour: 0, minute: 0 }]
433
+ });
434
+ case "@daily":
435
+ case "@midnight":
436
+ return newScheduleData({
437
+ type: "dayRepeat",
438
+ interval: 1,
439
+ days: { type: "every" },
440
+ times: [{ hour: 0, minute: 0 }]
441
+ });
442
+ case "@hourly":
443
+ return newScheduleData({
444
+ type: "intervalRepeat",
445
+ interval: 1,
446
+ unit: "hours",
447
+ from: { hour: 0, minute: 0 },
448
+ to: { hour: 23, minute: 59 },
449
+ dayFilter: null
450
+ });
451
+ default:
452
+ throw HronError.cron(`unknown @ shortcut: ${cron}`);
453
+ }
454
+ }
455
+ function parseMonthField(field) {
456
+ if (field === "*") return [];
457
+ const months = [];
458
+ for (const part of field.split(",")) {
459
+ if (part.includes("/")) {
460
+ const [rangePart, stepStr] = part.split("/");
461
+ let start, end;
462
+ if (rangePart === "*") {
463
+ start = 1;
464
+ end = 12;
465
+ } else if (rangePart.includes("-")) {
466
+ const [s, e] = rangePart.split("-");
467
+ start = monthNumber(parseMonthValue(s));
468
+ end = monthNumber(parseMonthValue(e));
469
+ } else {
470
+ throw HronError.cron(`invalid month step expression: ${part}`);
471
+ }
472
+ const step = parseInt(stepStr, 10);
473
+ if (Number.isNaN(step)) {
474
+ throw HronError.cron(`invalid month step value: ${stepStr}`);
475
+ }
476
+ if (step === 0) {
477
+ throw HronError.cron("step cannot be 0");
478
+ }
479
+ for (let n = start; n <= end; n += step) {
480
+ months.push(monthFromNumber(n));
481
+ }
482
+ } else if (part.includes("-")) {
483
+ const [startStr, endStr] = part.split("-");
484
+ const startMonth = parseMonthValue(startStr);
485
+ const endMonth = parseMonthValue(endStr);
486
+ const startNum = monthNumber(startMonth);
487
+ const endNum = monthNumber(endMonth);
488
+ if (startNum > endNum) {
489
+ throw HronError.cron(`invalid month range: ${startStr} > ${endStr}`);
490
+ }
491
+ for (let n = startNum; n <= endNum; n++) {
492
+ months.push(monthFromNumber(n));
493
+ }
494
+ } else {
495
+ months.push(parseMonthValue(part));
496
+ }
497
+ }
498
+ return months;
499
+ }
500
+ function parseMonthValue(s) {
501
+ const n = parseInt(s, 10);
502
+ if (!Number.isNaN(n)) {
503
+ return monthFromNumber(n);
504
+ }
505
+ const name = parseMonthName(s);
506
+ if (!name) {
507
+ throw HronError.cron(`invalid month: ${s}`);
508
+ }
509
+ return name;
510
+ }
511
+ function monthFromNumber(n) {
512
+ const map = {
513
+ 1: "jan",
514
+ 2: "feb",
515
+ 3: "mar",
516
+ 4: "apr",
517
+ 5: "may",
518
+ 6: "jun",
519
+ 7: "jul",
520
+ 8: "aug",
521
+ 9: "sep",
522
+ 10: "oct",
523
+ 11: "nov",
524
+ 12: "dec"
525
+ };
526
+ const result = map[n];
527
+ if (!result) {
528
+ throw HronError.cron(`invalid month number: ${n}`);
529
+ }
530
+ return result;
531
+ }
532
+ function tryParseNthWeekday(minuteField, hourField, domField, dowField, during) {
533
+ if (dowField.includes("#")) {
534
+ const [dowStr, nthStr] = dowField.split("#");
535
+ const dowNum = parseDowValue(dowStr);
536
+ const weekday = cronDowToWeekday(dowNum);
537
+ const nth = parseInt(nthStr, 10);
538
+ if (Number.isNaN(nth) || nth < 1 || nth > 5) {
539
+ throw HronError.cron(`nth must be 1-5, got ${nthStr}`);
540
+ }
541
+ if (domField !== "*" && domField !== "?") {
542
+ throw HronError.cron("DOM must be * when using # for nth weekday");
543
+ }
544
+ const minute = parseSingleValue(minuteField, "minute", 0, 59);
545
+ const hour = parseSingleValue(hourField, "hour", 0, 23);
546
+ const ordinalMap = {
547
+ 1: "first",
548
+ 2: "second",
549
+ 3: "third",
550
+ 4: "fourth",
551
+ 5: "fifth"
552
+ };
553
+ const schedule = newScheduleData({
554
+ type: "monthRepeat",
555
+ interval: 1,
556
+ target: { type: "ordinalWeekday", ordinal: ordinalMap[nth], weekday },
557
+ times: [{ hour, minute }]
558
+ });
559
+ schedule.during = during;
560
+ return schedule;
561
+ }
562
+ if (dowField.endsWith("L") && dowField.length > 1) {
563
+ const dowStr = dowField.slice(0, -1);
564
+ const dowNum = parseDowValue(dowStr);
565
+ const weekday = cronDowToWeekday(dowNum);
566
+ if (domField !== "*" && domField !== "?") {
567
+ throw HronError.cron("DOM must be * when using nL for last weekday");
568
+ }
569
+ const minute = parseSingleValue(minuteField, "minute", 0, 59);
570
+ const hour = parseSingleValue(hourField, "hour", 0, 23);
571
+ const schedule = newScheduleData({
572
+ type: "monthRepeat",
573
+ interval: 1,
574
+ target: { type: "ordinalWeekday", ordinal: "last", weekday },
575
+ times: [{ hour, minute }]
576
+ });
577
+ schedule.during = during;
578
+ return schedule;
579
+ }
580
+ return null;
581
+ }
582
+ function tryParseLastDay(minuteField, hourField, domField, dowField, during) {
583
+ if (domField !== "L" && domField !== "LW") {
584
+ return null;
585
+ }
586
+ if (dowField !== "*" && dowField !== "?") {
587
+ throw HronError.cron("DOW must be * when using L or LW in DOM");
588
+ }
589
+ const minute = parseSingleValue(minuteField, "minute", 0, 59);
590
+ const hour = parseSingleValue(hourField, "hour", 0, 23);
591
+ const target = domField === "LW" ? { type: "lastWeekday" } : { type: "lastDay" };
592
+ const schedule = newScheduleData({
593
+ type: "monthRepeat",
594
+ interval: 1,
595
+ target,
596
+ times: [{ hour, minute }]
597
+ });
598
+ schedule.during = during;
599
+ return schedule;
600
+ }
601
+ function tryParseNearestWeekday(minuteField, hourField, domField, dowField, during) {
602
+ if (!domField.endsWith("W") || domField === "LW") {
603
+ return null;
604
+ }
605
+ if (dowField !== "*" && dowField !== "?") {
606
+ throw HronError.cron("DOW must be * when using W in DOM");
607
+ }
608
+ const dayStr = domField.slice(0, -1);
609
+ const day = parseInt(dayStr, 10);
610
+ if (Number.isNaN(day)) {
611
+ throw HronError.cron(`invalid W day: ${dayStr}`);
612
+ }
613
+ if (day < 1 || day > 31) {
614
+ throw HronError.cron(`W day must be 1-31, got ${day}`);
615
+ }
616
+ const minute = parseSingleValue(minuteField, "minute", 0, 59);
617
+ const hour = parseSingleValue(hourField, "hour", 0, 23);
618
+ const target = {
619
+ type: "nearestWeekday",
620
+ day,
621
+ direction: null
622
+ };
623
+ const schedule = newScheduleData({
624
+ type: "monthRepeat",
625
+ interval: 1,
626
+ target,
627
+ times: [{ hour, minute }]
628
+ });
629
+ schedule.during = during;
630
+ return schedule;
631
+ }
632
+ function tryParseInterval(minuteField, hourField, domField, dowField, during) {
633
+ if (minuteField.includes("/")) {
634
+ const [rangePart, stepStr] = minuteField.split("/");
635
+ const interval = parseInt(stepStr, 10);
636
+ if (Number.isNaN(interval)) {
637
+ throw HronError.cron("invalid minute interval value");
638
+ }
639
+ if (interval === 0) {
640
+ throw HronError.cron("step cannot be 0");
641
+ }
642
+ let fromMinute, toMinute;
643
+ if (rangePart === "*") {
644
+ fromMinute = 0;
645
+ toMinute = 59;
646
+ } else if (rangePart.includes("-")) {
647
+ const [s, e] = rangePart.split("-");
648
+ fromMinute = parseInt(s, 10);
649
+ toMinute = parseInt(e, 10);
650
+ if (Number.isNaN(fromMinute) || Number.isNaN(toMinute)) {
651
+ throw HronError.cron("invalid minute range");
652
+ }
653
+ if (fromMinute > toMinute) {
654
+ throw HronError.cron(
655
+ `range start must be <= end: ${fromMinute}-${toMinute}`
656
+ );
657
+ }
658
+ } else {
659
+ fromMinute = parseInt(rangePart, 10);
660
+ if (Number.isNaN(fromMinute)) {
661
+ throw HronError.cron("invalid minute value");
662
+ }
663
+ toMinute = 59;
664
+ }
665
+ let fromHour, toHour;
333
666
  if (hourField === "*") {
334
- } else if (hourField.includes("-")) {
335
- const [start, end] = hourField.split("-");
336
- fromHour = parseInt(start, 10);
337
- toHour = parseInt(end, 10);
338
- if (Number.isNaN(fromHour) || Number.isNaN(toHour))
667
+ fromHour = 0;
668
+ toHour = 23;
669
+ } else if (hourField.includes("-") && !hourField.includes("/")) {
670
+ const [s, e] = hourField.split("-");
671
+ fromHour = parseInt(s, 10);
672
+ toHour = parseInt(e, 10);
673
+ if (Number.isNaN(fromHour) || Number.isNaN(toHour)) {
339
674
  throw HronError.cron("invalid hour range");
675
+ }
676
+ } else if (hourField.includes("/")) {
677
+ return null;
340
678
  } else {
341
679
  const h = parseInt(hourField, 10);
342
- if (Number.isNaN(h)) throw HronError.cron("invalid hour");
680
+ if (Number.isNaN(h)) {
681
+ throw HronError.cron("invalid hour");
682
+ }
343
683
  fromHour = h;
344
684
  toHour = h;
345
685
  }
346
686
  const dayFilter = dowField === "*" ? null : parseCronDow(dowField);
347
- if (domField === "*") {
348
- const expr2 = {
687
+ if (domField === "*" || domField === "?") {
688
+ let endMinute;
689
+ if (fromMinute === 0 && toMinute === 59 && toHour === 23) {
690
+ endMinute = 59;
691
+ } else if (fromMinute === 0 && toMinute === 59) {
692
+ endMinute = 0;
693
+ } else {
694
+ endMinute = toMinute;
695
+ }
696
+ const schedule = newScheduleData({
349
697
  type: "intervalRepeat",
350
698
  interval,
351
699
  unit: "min",
352
- from: { hour: fromHour, minute: 0 },
353
- to: { hour: toHour, minute: toHour === 23 ? 59 : 0 },
700
+ from: { hour: fromHour, minute: fromMinute },
701
+ to: { hour: toHour, minute: endMinute },
354
702
  dayFilter
355
- };
356
- return newScheduleData(expr2);
703
+ });
704
+ schedule.during = during;
705
+ return schedule;
706
+ }
707
+ }
708
+ if (hourField.includes("/") && (minuteField === "0" || minuteField === "00")) {
709
+ const [rangePart, stepStr] = hourField.split("/");
710
+ const interval = parseInt(stepStr, 10);
711
+ if (Number.isNaN(interval)) {
712
+ throw HronError.cron("invalid hour interval value");
713
+ }
714
+ if (interval === 0) {
715
+ throw HronError.cron("step cannot be 0");
716
+ }
717
+ let fromHour, toHour;
718
+ if (rangePart === "*") {
719
+ fromHour = 0;
720
+ toHour = 23;
721
+ } else if (rangePart.includes("-")) {
722
+ const [s, e] = rangePart.split("-");
723
+ fromHour = parseInt(s, 10);
724
+ toHour = parseInt(e, 10);
725
+ if (Number.isNaN(fromHour) || Number.isNaN(toHour)) {
726
+ throw HronError.cron("invalid hour range");
727
+ }
728
+ if (fromHour > toHour) {
729
+ throw HronError.cron(
730
+ `range start must be <= end: ${fromHour}-${toHour}`
731
+ );
732
+ }
733
+ } else {
734
+ fromHour = parseInt(rangePart, 10);
735
+ if (Number.isNaN(fromHour)) {
736
+ throw HronError.cron("invalid hour value");
737
+ }
738
+ toHour = 23;
357
739
  }
358
- }
359
- if (hourField.startsWith("*/") && minuteField === "0") {
360
- const interval = parseInt(hourField.slice(2), 10);
361
- if (Number.isNaN(interval)) throw HronError.cron("invalid hour interval");
362
- if (domField === "*" && dowField === "*") {
363
- const expr2 = {
740
+ if ((domField === "*" || domField === "?") && (dowField === "*" || dowField === "?")) {
741
+ const endMinute = fromHour === 0 && toHour === 23 ? 59 : 0;
742
+ const schedule = newScheduleData({
364
743
  type: "intervalRepeat",
365
744
  interval,
366
745
  unit: "hours",
367
- from: { hour: 0, minute: 0 },
368
- to: { hour: 23, minute: 59 },
746
+ from: { hour: fromHour, minute: 0 },
747
+ to: { hour: toHour, minute: endMinute },
369
748
  dayFilter: null
370
- };
371
- return newScheduleData(expr2);
749
+ });
750
+ schedule.during = during;
751
+ return schedule;
372
752
  }
373
753
  }
374
- const minute = parseInt(minuteField, 10);
375
- if (Number.isNaN(minute))
376
- throw HronError.cron(`invalid minute field: ${minuteField}`);
377
- const hour = parseInt(hourField, 10);
378
- if (Number.isNaN(hour))
379
- throw HronError.cron(`invalid hour field: ${hourField}`);
380
- const time = { hour, minute };
381
- if (domField !== "*" && dowField === "*") {
382
- if (domField.includes("-")) {
383
- throw HronError.cron(`DOM ranges not supported: ${domField}`);
754
+ return null;
755
+ }
756
+ function parseDomField(field) {
757
+ const specs = [];
758
+ for (const part of field.split(",")) {
759
+ if (part.includes("/")) {
760
+ const [rangePart, stepStr] = part.split("/");
761
+ let start, end;
762
+ if (rangePart === "*") {
763
+ start = 1;
764
+ end = 31;
765
+ } else if (rangePart.includes("-")) {
766
+ const [s, e] = rangePart.split("-");
767
+ start = parseInt(s, 10);
768
+ end = parseInt(e, 10);
769
+ if (Number.isNaN(start)) {
770
+ throw HronError.cron(`invalid DOM range start: ${s}`);
771
+ }
772
+ if (Number.isNaN(end)) {
773
+ throw HronError.cron(`invalid DOM range end: ${e}`);
774
+ }
775
+ if (start > end) {
776
+ throw HronError.cron(`range start must be <= end: ${start}-${end}`);
777
+ }
778
+ } else {
779
+ start = parseInt(rangePart, 10);
780
+ if (Number.isNaN(start)) {
781
+ throw HronError.cron(`invalid DOM value: ${rangePart}`);
782
+ }
783
+ end = 31;
784
+ }
785
+ const step = parseInt(stepStr, 10);
786
+ if (Number.isNaN(step)) {
787
+ throw HronError.cron(`invalid DOM step: ${stepStr}`);
788
+ }
789
+ if (step === 0) {
790
+ throw HronError.cron("step cannot be 0");
791
+ }
792
+ validateDom(start);
793
+ validateDom(end);
794
+ for (let d = start; d <= end; d += step) {
795
+ specs.push({ type: "single", day: d });
796
+ }
797
+ } else if (part.includes("-")) {
798
+ const [startStr, endStr] = part.split("-");
799
+ const start = parseInt(startStr, 10);
800
+ const end = parseInt(endStr, 10);
801
+ if (Number.isNaN(start)) {
802
+ throw HronError.cron(`invalid DOM range start: ${startStr}`);
803
+ }
804
+ if (Number.isNaN(end)) {
805
+ throw HronError.cron(`invalid DOM range end: ${endStr}`);
806
+ }
807
+ if (start > end) {
808
+ throw HronError.cron(`range start must be <= end: ${start}-${end}`);
809
+ }
810
+ validateDom(start);
811
+ validateDom(end);
812
+ specs.push({ type: "range", start, end });
813
+ } else {
814
+ const day = parseInt(part, 10);
815
+ if (Number.isNaN(day)) {
816
+ throw HronError.cron(`invalid DOM value: ${part}`);
817
+ }
818
+ validateDom(day);
819
+ specs.push({ type: "single", day });
384
820
  }
385
- const dayNums = domField.split(",").map((s) => {
386
- const n = parseInt(s, 10);
387
- if (Number.isNaN(n))
388
- throw HronError.cron(`invalid DOM field: ${domField}`);
389
- return n;
390
- });
391
- const specs = dayNums.map((d) => ({
392
- type: "single",
393
- day: d
394
- }));
395
- const expr2 = {
396
- type: "monthRepeat",
397
- interval: 1,
398
- target: { type: "days", specs },
399
- times: [time]
400
- };
401
- return newScheduleData(expr2);
402
821
  }
403
- const days = parseCronDow(dowField);
404
- const expr = {
405
- type: "dayRepeat",
406
- interval: 1,
407
- days,
408
- times: [time]
409
- };
410
- return newScheduleData(expr);
822
+ return { type: "days", specs };
823
+ }
824
+ function validateDom(day) {
825
+ if (day < 1 || day > 31) {
826
+ throw HronError.cron(`DOM must be 1-31, got ${day}`);
827
+ }
411
828
  }
412
829
  function parseCronDow(field) {
413
830
  if (field === "*") return { type: "every" };
414
- if (field === "1-5") return { type: "weekday" };
415
- if (field === "0,6" || field === "6,0") return { type: "weekend" };
416
- if (field.includes("-")) {
417
- throw HronError.cron(`DOW ranges not supported: ${field}`);
418
- }
419
- const nums = field.split(",").map((s) => {
420
- const n = parseInt(s, 10);
421
- if (Number.isNaN(n)) throw HronError.cron(`invalid DOW field: ${field}`);
422
- return n;
423
- });
424
- const days = nums.map((n) => cronDowToWeekday(n));
831
+ const days = [];
832
+ for (const part of field.split(",")) {
833
+ if (part.includes("/")) {
834
+ const [rangePart, stepStr] = part.split("/");
835
+ let start, end;
836
+ if (rangePart === "*") {
837
+ start = 0;
838
+ end = 6;
839
+ } else if (rangePart.includes("-")) {
840
+ const [s, e] = rangePart.split("-");
841
+ start = parseDowValueRaw(s);
842
+ end = parseDowValueRaw(e);
843
+ if (start > end) {
844
+ throw HronError.cron(`range start must be <= end: ${s}-${e}`);
845
+ }
846
+ } else {
847
+ start = parseDowValueRaw(rangePart);
848
+ end = 6;
849
+ }
850
+ const step = parseInt(stepStr, 10);
851
+ if (Number.isNaN(step)) {
852
+ throw HronError.cron(`invalid DOW step: ${stepStr}`);
853
+ }
854
+ if (step === 0) {
855
+ throw HronError.cron("step cannot be 0");
856
+ }
857
+ for (let d = start; d <= end; d += step) {
858
+ const normalized = d === 7 ? 0 : d;
859
+ days.push(cronDowToWeekday(normalized));
860
+ }
861
+ } else if (part.includes("-")) {
862
+ const [startStr, endStr] = part.split("-");
863
+ const start = parseDowValueRaw(startStr);
864
+ const end = parseDowValueRaw(endStr);
865
+ if (start > end) {
866
+ throw HronError.cron(
867
+ `range start must be <= end: ${startStr}-${endStr}`
868
+ );
869
+ }
870
+ for (let d = start; d <= end; d++) {
871
+ const normalized = d === 7 ? 0 : d;
872
+ days.push(cronDowToWeekday(normalized));
873
+ }
874
+ } else {
875
+ const dow = parseDowValue(part);
876
+ days.push(cronDowToWeekday(dow));
877
+ }
878
+ }
879
+ if (days.length === 5) {
880
+ const sorted = [...days].sort(
881
+ (a, b) => weekdayNumber(a) - weekdayNumber(b)
882
+ );
883
+ const weekdays = [...ALL_WEEKDAYS].sort(
884
+ (a, b) => weekdayNumber(a) - weekdayNumber(b)
885
+ );
886
+ if (JSON.stringify(sorted) === JSON.stringify(weekdays)) {
887
+ return { type: "weekday" };
888
+ }
889
+ }
890
+ if (days.length === 2) {
891
+ const sorted = [...days].sort(
892
+ (a, b) => weekdayNumber(a) - weekdayNumber(b)
893
+ );
894
+ const weekend = [...ALL_WEEKEND].sort(
895
+ (a, b) => weekdayNumber(a) - weekdayNumber(b)
896
+ );
897
+ if (JSON.stringify(sorted) === JSON.stringify(weekend)) {
898
+ return { type: "weekend" };
899
+ }
900
+ }
425
901
  return { type: "days", days };
426
902
  }
903
+ function parseDowValue(s) {
904
+ const raw = parseDowValueRaw(s);
905
+ return raw === 7 ? 0 : raw;
906
+ }
907
+ function parseDowValueRaw(s) {
908
+ const n = parseInt(s, 10);
909
+ if (!Number.isNaN(n)) {
910
+ if (n > 7) {
911
+ throw HronError.cron(`DOW must be 0-7, got ${n}`);
912
+ }
913
+ return n;
914
+ }
915
+ const map = {
916
+ SUN: 0,
917
+ MON: 1,
918
+ TUE: 2,
919
+ WED: 3,
920
+ THU: 4,
921
+ FRI: 5,
922
+ SAT: 6
923
+ };
924
+ const result = map[s.toUpperCase()];
925
+ if (result === void 0) {
926
+ throw HronError.cron(`invalid DOW: ${s}`);
927
+ }
928
+ return result;
929
+ }
427
930
  function cronDowToWeekday(n) {
428
931
  const map = {
429
932
  0: "sunday",
@@ -436,9 +939,21 @@ function cronDowToWeekday(n) {
436
939
  7: "sunday"
437
940
  };
438
941
  const result = map[n];
439
- if (!result) throw HronError.cron(`invalid DOW number: ${n}`);
942
+ if (!result) {
943
+ throw HronError.cron(`invalid DOW number: ${n}`);
944
+ }
440
945
  return result;
441
946
  }
947
+ function parseSingleValue(field, name, min, max) {
948
+ const value = parseInt(field, 10);
949
+ if (Number.isNaN(value)) {
950
+ throw HronError.cron(`invalid ${name} field: ${field}`);
951
+ }
952
+ if (value < min || value > max) {
953
+ throw HronError.cron(`${name} must be ${min}-${max}, got ${value}`);
954
+ }
955
+ return value;
956
+ }
442
957
 
443
958
  // src/display.ts
444
959
  function display(schedule) {
@@ -491,19 +1006,24 @@ function displayExpr(expr) {
491
1006
  targetStr = formatOrdinalDaySpecs(expr.target.specs);
492
1007
  } else if (expr.target.type === "lastDay") {
493
1008
  targetStr = "last day";
494
- } else {
1009
+ } else if (expr.target.type === "lastWeekday") {
495
1010
  targetStr = "last weekday";
1011
+ } else if (expr.target.type === "ordinalWeekday") {
1012
+ targetStr = `${expr.target.ordinal} ${expr.target.weekday}`;
1013
+ } else if (expr.target.type === "nearestWeekday") {
1014
+ const { day, direction } = expr.target;
1015
+ const dirPrefix = direction ? `${direction} ` : "";
1016
+ targetStr = `${dirPrefix}nearest weekday to ${day}${ordinalSuffix(day)}`;
1017
+ } else {
1018
+ throw new Error(
1019
+ `unknown month target type: ${expr.target.type}`
1020
+ );
496
1021
  }
497
1022
  if (expr.interval > 1) {
498
1023
  return `every ${expr.interval} months on the ${targetStr} at ${formatTimeList(expr.times)}`;
499
1024
  }
500
1025
  return `every month on the ${targetStr} at ${formatTimeList(expr.times)}`;
501
1026
  }
502
- case "ordinalRepeat":
503
- if (expr.interval > 1) {
504
- return `${expr.ordinal} ${expr.day} of every ${expr.interval} months at ${formatTimeList(expr.times)}`;
505
- }
506
- return `${expr.ordinal} ${expr.day} of every month at ${formatTimeList(expr.times)}`;
507
1027
  case "singleDate": {
508
1028
  let dateStr;
509
1029
  if (expr.date.type === "named") {
@@ -521,14 +1041,24 @@ function displayExpr(expr) {
521
1041
  targetStr = `the ${expr.target.ordinal} ${expr.target.weekday} of ${expr.target.month}`;
522
1042
  } else if (expr.target.type === "dayOfMonth") {
523
1043
  targetStr = `the ${expr.target.day}${ordinalSuffix(expr.target.day)} of ${expr.target.month}`;
524
- } else {
1044
+ } else if (expr.target.type === "lastWeekday") {
525
1045
  targetStr = `the last weekday of ${expr.target.month}`;
1046
+ } else {
1047
+ throw new Error(
1048
+ `unknown year target type: ${expr.target.type}`
1049
+ );
526
1050
  }
527
1051
  if (expr.interval > 1) {
528
1052
  return `every ${expr.interval} years on ${targetStr} at ${formatTimeList(expr.times)}`;
529
1053
  }
530
1054
  return `every year on ${targetStr} at ${formatTimeList(expr.times)}`;
531
1055
  }
1056
+ default: {
1057
+ const _exhaustive = expr;
1058
+ throw new Error(
1059
+ `unknown expression type: ${_exhaustive.type}`
1060
+ );
1061
+ }
532
1062
  }
533
1063
  }
534
1064
  function displayDayFilter(filter) {
@@ -541,6 +1071,12 @@ function displayDayFilter(filter) {
541
1071
  return "weekend";
542
1072
  case "days":
543
1073
  return formatDayList(filter.days);
1074
+ default: {
1075
+ const _exhaustive = filter;
1076
+ throw new Error(
1077
+ `unknown day filter type: ${_exhaustive.type}`
1078
+ );
1079
+ }
544
1080
  }
545
1081
  }
546
1082
  function formatTime(t) {
@@ -620,6 +1156,40 @@ function lastWeekdayOfMonth(year, month) {
620
1156
  }
621
1157
  return d;
622
1158
  }
1159
+ function nearestWeekday(year, month, targetDay, direction) {
1160
+ const last = lastDayOfMonth(year, month);
1161
+ const lastDay = last.day;
1162
+ if (targetDay > lastDay) {
1163
+ return null;
1164
+ }
1165
+ const date = import_polyfill.Temporal.PlainDate.from({ year, month, day: targetDay });
1166
+ const dow = date.dayOfWeek;
1167
+ if (dow >= 1 && dow <= 5) {
1168
+ return date;
1169
+ }
1170
+ if (dow === 6) {
1171
+ if (direction === null) {
1172
+ if (targetDay === 1) {
1173
+ return date.add({ days: 2 });
1174
+ }
1175
+ return date.subtract({ days: 1 });
1176
+ }
1177
+ if (direction === "next") {
1178
+ return date.add({ days: 2 });
1179
+ }
1180
+ return date.subtract({ days: 1 });
1181
+ }
1182
+ if (direction === null) {
1183
+ if (targetDay >= lastDay) {
1184
+ return date.subtract({ days: 2 });
1185
+ }
1186
+ return date.add({ days: 1 });
1187
+ }
1188
+ if (direction === "next") {
1189
+ return date.add({ days: 1 });
1190
+ }
1191
+ return date.subtract({ days: 2 });
1192
+ }
623
1193
  function nthWeekdayOfMonth(year, month, weekday, n) {
624
1194
  const targetDow = weekdayNameToNumber(weekday);
625
1195
  let d = import_polyfill.Temporal.PlainDate.from({ year, month, day: 1 });
@@ -762,18 +1332,24 @@ function nextFrom(schedule, now) {
762
1332
  const parsedExceptions = parseExceptions(schedule.except);
763
1333
  const hasExceptions = schedule.except.length > 0;
764
1334
  const hasDuring = schedule.during.length > 0;
765
- const needsTzConversion = untilDate !== null || hasDuring || hasExceptions;
1335
+ const handlesDuringInternally = schedule.expr.type === "monthRepeat" && schedule.expr.target.type === "nearestWeekday" && schedule.expr.target.direction !== null;
766
1336
  let current = now;
767
1337
  for (let i = 0; i < 1e3; i++) {
768
- const candidate = nextExpr(schedule.expr, tz, schedule.anchor, current);
1338
+ const candidate = nextExpr(
1339
+ schedule.expr,
1340
+ tz,
1341
+ schedule.anchor,
1342
+ current,
1343
+ schedule.during
1344
+ );
769
1345
  if (candidate === null) return null;
770
- const cDate = needsTzConversion ? candidate.withTimeZone(tz).toPlainDate() : null;
1346
+ const cDate = candidate.withTimeZone(tz).toPlainDate();
771
1347
  if (untilDate) {
772
1348
  if (import_polyfill.Temporal.PlainDate.compare(cDate, untilDate) > 0) {
773
1349
  return null;
774
1350
  }
775
1351
  }
776
- if (hasDuring && !matchesDuring(cDate, schedule.during)) {
1352
+ if (hasDuring && !handlesDuringInternally && !matchesDuring(cDate, schedule.during)) {
777
1353
  const skipTo = nextDuringMonth(cDate, schedule.during);
778
1354
  current = atTimeOnDate(skipTo, MIDNIGHT, tz).subtract({ seconds: 1 });
779
1355
  continue;
@@ -787,7 +1363,7 @@ function nextFrom(schedule, now) {
787
1363
  }
788
1364
  return null;
789
1365
  }
790
- function nextExpr(expr, tz, anchor, now) {
1366
+ function nextExpr(expr, tz, anchor, now, during) {
791
1367
  switch (expr.type) {
792
1368
  case "dayRepeat":
793
1369
  return nextDayRepeat(
@@ -824,17 +1400,8 @@ function nextExpr(expr, tz, anchor, now) {
824
1400
  expr.times,
825
1401
  tz,
826
1402
  anchor,
827
- now
828
- );
829
- case "ordinalRepeat":
830
- return nextOrdinalRepeat(
831
- expr.interval,
832
- expr.ordinal,
833
- expr.day,
834
- expr.times,
835
- tz,
836
- anchor,
837
- now
1403
+ now,
1404
+ during
838
1405
  );
839
1406
  case "singleDate":
840
1407
  return nextSingleDate(expr.date, expr.times, tz, now);
@@ -926,30 +1493,35 @@ function matches(schedule, datetime) {
926
1493
  const last = lastDayOfMonth(date.year, date.month);
927
1494
  return import_polyfill.Temporal.PlainDate.compare(date, last) === 0;
928
1495
  }
929
- const lastWd = lastWeekdayOfMonth(date.year, date.month);
930
- return import_polyfill.Temporal.PlainDate.compare(date, lastWd) === 0;
931
- }
932
- case "ordinalRepeat": {
933
- if (!timeMatchesWithDst(schedule.expr.times)) return false;
934
- if (schedule.expr.interval > 1) {
935
- const anchorDate = schedule.anchor ? import_polyfill.Temporal.PlainDate.from(schedule.anchor) : EPOCH_DATE;
936
- const monthOffset = monthsBetweenYM(anchorDate, date);
937
- if (monthOffset < 0 || monthOffset % schedule.expr.interval !== 0) {
938
- return false;
939
- }
1496
+ if (target.type === "lastWeekday") {
1497
+ const lastWd = lastWeekdayOfMonth(date.year, date.month);
1498
+ return import_polyfill.Temporal.PlainDate.compare(date, lastWd) === 0;
940
1499
  }
941
- const { ordinal, day } = schedule.expr;
942
- let targetDate;
943
- if (ordinal === "last") {
944
- targetDate = lastWeekdayInMonth(date.year, date.month, day);
945
- } else {
946
- targetDate = nthWeekdayOfMonth(
947
- date.year,
948
- date.month,
949
- day,
950
- ordinalToN(ordinal)
951
- );
1500
+ if (target.type === "ordinalWeekday") {
1501
+ let targetDate2;
1502
+ if (target.ordinal === "last") {
1503
+ targetDate2 = lastWeekdayInMonth(
1504
+ date.year,
1505
+ date.month,
1506
+ target.weekday
1507
+ );
1508
+ } else {
1509
+ targetDate2 = nthWeekdayOfMonth(
1510
+ date.year,
1511
+ date.month,
1512
+ target.weekday,
1513
+ ordinalToN(target.ordinal)
1514
+ );
1515
+ }
1516
+ if (!targetDate2) return false;
1517
+ return import_polyfill.Temporal.PlainDate.compare(date, targetDate2) === 0;
952
1518
  }
1519
+ const targetDate = nearestWeekday(
1520
+ date.year,
1521
+ date.month,
1522
+ target.day,
1523
+ target.direction
1524
+ );
953
1525
  if (!targetDate) return false;
954
1526
  return import_polyfill.Temporal.PlainDate.compare(date, targetDate) === 0;
955
1527
  }
@@ -1082,8 +1654,7 @@ function nextWeekRepeat(interval, days, times, tz, anchor, now) {
1082
1654
  for (let i = 0; i < 54; i++) {
1083
1655
  const weeks = weeksBetween(anchorMonday, currentMonday);
1084
1656
  if (weeks < 0) {
1085
- const skip = Math.ceil(-weeks / interval);
1086
- currentMonday = currentMonday.add({ days: skip * interval * 7 });
1657
+ currentMonday = anchorMonday;
1087
1658
  continue;
1088
1659
  }
1089
1660
  if (weeks % interval === 0) {
@@ -1100,13 +1671,22 @@ function nextWeekRepeat(interval, days, times, tz, anchor, now) {
1100
1671
  }
1101
1672
  return null;
1102
1673
  }
1103
- function nextMonthRepeat(interval, target, times, tz, anchor, now) {
1674
+ function nextMonthRepeat(interval, target, times, tz, anchor, now, during) {
1104
1675
  const nowInTz = now.withTimeZone(tz);
1105
1676
  let year = nowInTz.year;
1106
1677
  let month = nowInTz.month;
1107
1678
  const anchorDate = anchor ? import_polyfill.Temporal.PlainDate.from(anchor) : EPOCH_DATE;
1108
1679
  const maxIter = interval > 1 ? 24 * interval : 24;
1680
+ const applyDuringFilter = during.length > 0 && target.type === "nearestWeekday" && target.direction !== null;
1109
1681
  for (let i = 0; i < maxIter; i++) {
1682
+ if (applyDuringFilter && !during.some((mn) => monthNumber(mn) === month)) {
1683
+ month++;
1684
+ if (month > 12) {
1685
+ month = 1;
1686
+ year++;
1687
+ }
1688
+ continue;
1689
+ }
1110
1690
  if (interval > 1) {
1111
1691
  const cur = import_polyfill.Temporal.PlainDate.from({ year, month, day: 1 });
1112
1692
  const monthOffset = monthsBetweenYM(anchorDate, cur);
@@ -1135,161 +1715,567 @@ function nextMonthRepeat(interval, target, times, tz, anchor, now) {
1135
1715
  }
1136
1716
  } else if (target.type === "lastDay") {
1137
1717
  dateCandidates.push(lastDayOfMonth(year, month));
1138
- } else {
1718
+ } else if (target.type === "lastWeekday") {
1139
1719
  dateCandidates.push(lastWeekdayOfMonth(year, month));
1720
+ } else if (target.type === "ordinalWeekday") {
1721
+ let owDate;
1722
+ if (target.ordinal === "last") {
1723
+ owDate = lastWeekdayInMonth(year, month, target.weekday);
1724
+ } else {
1725
+ owDate = nthWeekdayOfMonth(
1726
+ year,
1727
+ month,
1728
+ target.weekday,
1729
+ ordinalToN(target.ordinal)
1730
+ );
1731
+ }
1732
+ if (owDate) {
1733
+ dateCandidates.push(owDate);
1734
+ }
1735
+ } else {
1736
+ const nwDate = nearestWeekday(year, month, target.day, target.direction);
1737
+ if (nwDate) {
1738
+ dateCandidates.push(nwDate);
1739
+ }
1740
+ }
1741
+ let best = null;
1742
+ for (const date of dateCandidates) {
1743
+ const candidate = earliestFutureAtTimes(date, times, tz, now);
1744
+ if (candidate) {
1745
+ if (best === null || import_polyfill.Temporal.ZonedDateTime.compare(candidate, best) < 0) {
1746
+ best = candidate;
1747
+ }
1748
+ }
1749
+ }
1750
+ if (best) return best;
1751
+ month++;
1752
+ if (month > 12) {
1753
+ month = 1;
1754
+ year++;
1755
+ }
1756
+ }
1757
+ return null;
1758
+ }
1759
+ function nextSingleDate(dateSpec, times, tz, now) {
1760
+ const nowInTz = now.withTimeZone(tz);
1761
+ if (dateSpec.type === "iso") {
1762
+ const date = import_polyfill.Temporal.PlainDate.from(dateSpec.date);
1763
+ return earliestFutureAtTimes(date, times, tz, now);
1764
+ }
1765
+ if (dateSpec.type === "named") {
1766
+ const startYear = nowInTz.year;
1767
+ for (let y = 0; y < 8; y++) {
1768
+ const year = startYear + y;
1769
+ try {
1770
+ const date = import_polyfill.Temporal.PlainDate.from(
1771
+ {
1772
+ year,
1773
+ month: monthNumber(dateSpec.month),
1774
+ day: dateSpec.day
1775
+ },
1776
+ { overflow: "reject" }
1777
+ );
1778
+ const candidate = earliestFutureAtTimes(date, times, tz, now);
1779
+ if (candidate) return candidate;
1780
+ } catch {
1781
+ }
1782
+ }
1783
+ return null;
1784
+ }
1785
+ return null;
1786
+ }
1787
+ function nextYearRepeat(interval, target, times, tz, anchor, now) {
1788
+ const nowInTz = now.withTimeZone(tz);
1789
+ const startYear = nowInTz.year;
1790
+ const anchorYear = anchor ? import_polyfill.Temporal.PlainDate.from(anchor).year : EPOCH_DATE.year;
1791
+ const maxIter = interval > 1 ? 8 * interval : 8;
1792
+ for (let y = 0; y < maxIter; y++) {
1793
+ const year = startYear + y;
1794
+ if (interval > 1) {
1795
+ const yearOffset = year - anchorYear;
1796
+ if (yearOffset < 0 || euclideanMod(yearOffset, interval) !== 0) {
1797
+ continue;
1798
+ }
1799
+ }
1800
+ let targetDate = null;
1801
+ switch (target.type) {
1802
+ case "date":
1803
+ try {
1804
+ targetDate = import_polyfill.Temporal.PlainDate.from(
1805
+ {
1806
+ year,
1807
+ month: monthNumber(target.month),
1808
+ day: target.day
1809
+ },
1810
+ { overflow: "reject" }
1811
+ );
1812
+ } catch {
1813
+ continue;
1814
+ }
1815
+ break;
1816
+ case "ordinalWeekday":
1817
+ if (target.ordinal === "last") {
1818
+ targetDate = lastWeekdayInMonth(
1819
+ year,
1820
+ monthNumber(target.month),
1821
+ target.weekday
1822
+ );
1823
+ } else {
1824
+ targetDate = nthWeekdayOfMonth(
1825
+ year,
1826
+ monthNumber(target.month),
1827
+ target.weekday,
1828
+ ordinalToN(target.ordinal)
1829
+ );
1830
+ }
1831
+ break;
1832
+ case "dayOfMonth":
1833
+ try {
1834
+ targetDate = import_polyfill.Temporal.PlainDate.from(
1835
+ {
1836
+ year,
1837
+ month: monthNumber(target.month),
1838
+ day: target.day
1839
+ },
1840
+ { overflow: "reject" }
1841
+ );
1842
+ } catch {
1843
+ continue;
1844
+ }
1845
+ break;
1846
+ case "lastWeekday":
1847
+ targetDate = lastWeekdayOfMonth(year, monthNumber(target.month));
1848
+ break;
1849
+ }
1850
+ if (targetDate) {
1851
+ const candidate = earliestFutureAtTimes(targetDate, times, tz, now);
1852
+ if (candidate) return candidate;
1853
+ }
1854
+ }
1855
+ return null;
1856
+ }
1857
+ function previousFrom(schedule, now) {
1858
+ const tz = resolveTz(schedule.timezone);
1859
+ const anchor = schedule.anchor;
1860
+ const parsedExceptions = parseExceptions(schedule.except);
1861
+ const hasExceptions = schedule.except.length > 0;
1862
+ const hasDuring = schedule.during.length > 0;
1863
+ let current = now;
1864
+ for (let i = 0; i < 1e3; i++) {
1865
+ const candidate = prevExpr(schedule, tz, anchor, current);
1866
+ if (candidate === null) return null;
1867
+ const cDate = candidate.withTimeZone(tz).toPlainDate();
1868
+ if (anchor) {
1869
+ const anchorDate = import_polyfill.Temporal.PlainDate.from(anchor);
1870
+ if (import_polyfill.Temporal.PlainDate.compare(cDate, anchorDate) < 0) {
1871
+ return null;
1872
+ }
1873
+ }
1874
+ if (schedule.until) {
1875
+ const untilDate = resolveUntil(schedule.until, now);
1876
+ if (import_polyfill.Temporal.PlainDate.compare(cDate, untilDate) > 0) {
1877
+ const endOfDay = toPlainTime({ hour: 23, minute: 59 });
1878
+ const skipTo = atTimeOnDate(untilDate, endOfDay, tz);
1879
+ current = skipTo.add({ seconds: 1 });
1880
+ continue;
1881
+ }
1882
+ }
1883
+ if (hasDuring && !matchesDuring(cDate, schedule.during)) {
1884
+ const skipTo = prevDuringMonth(cDate, schedule.during);
1885
+ const endOfDay = toPlainTime({ hour: 23, minute: 59 });
1886
+ current = atTimeOnDate(skipTo, endOfDay, tz).add({
1887
+ seconds: 1
1888
+ });
1889
+ continue;
1890
+ }
1891
+ if (hasExceptions && isExceptedParsed(cDate, parsedExceptions)) {
1892
+ const prevDay = cDate.subtract({ days: 1 });
1893
+ const endOfDay = toPlainTime({ hour: 23, minute: 59 });
1894
+ current = atTimeOnDate(prevDay, endOfDay, tz).add({
1895
+ seconds: 1
1896
+ });
1897
+ continue;
1898
+ }
1899
+ return candidate;
1900
+ }
1901
+ return null;
1902
+ }
1903
+ function prevExpr(schedule, tz, anchor, now) {
1904
+ const expr = schedule.expr;
1905
+ switch (expr.type) {
1906
+ case "dayRepeat":
1907
+ return prevDayRepeat(expr, tz, anchor, now);
1908
+ case "intervalRepeat":
1909
+ return prevIntervalRepeat(expr, tz, now);
1910
+ case "weekRepeat":
1911
+ return prevWeekRepeat(expr, tz, anchor, now);
1912
+ case "monthRepeat":
1913
+ return prevMonthRepeat(expr, tz, anchor, now);
1914
+ case "singleDate":
1915
+ return prevSingleDate(expr, tz, now);
1916
+ case "yearRepeat":
1917
+ return prevYearRepeat(expr, tz, anchor, now);
1918
+ default:
1919
+ return null;
1920
+ }
1921
+ }
1922
+ function prevDayRepeat(expr, tz, anchor, now) {
1923
+ const nowInTz = now.withTimeZone(tz);
1924
+ let date = nowInTz.toPlainDate();
1925
+ const { interval, days, times } = expr;
1926
+ if (interval <= 1) {
1927
+ if (matchesDayFilter(date, days)) {
1928
+ const candidate = latestPastAtTimes(date, times, tz, now);
1929
+ if (candidate !== null) return candidate;
1930
+ }
1931
+ for (let i = 0; i < 8; i++) {
1932
+ date = date.subtract({ days: 1 });
1933
+ if (matchesDayFilter(date, days)) {
1934
+ const candidate = latestAtTimes(date, times, tz);
1935
+ if (candidate !== null) return candidate;
1936
+ }
1937
+ }
1938
+ return null;
1939
+ }
1940
+ const anchorDate = anchor ? import_polyfill.Temporal.PlainDate.from(anchor) : EPOCH_DATE;
1941
+ const offset = daysBetween(anchorDate, date);
1942
+ const remainder = (offset % interval + interval) % interval;
1943
+ const alignedDate = remainder === 0 ? date : date.subtract({ days: remainder });
1944
+ for (let i = 0; i < 2; i++) {
1945
+ const checkDate = alignedDate.subtract({ days: i * interval });
1946
+ const candidate = latestPastAtTimes(checkDate, times, tz, now);
1947
+ if (candidate !== null) return candidate;
1948
+ const latest = latestAtTimes(checkDate, times, tz);
1949
+ if (latest !== null && import_polyfill.Temporal.ZonedDateTime.compare(latest, now) < 0) {
1950
+ return latest;
1951
+ }
1952
+ }
1953
+ return null;
1954
+ }
1955
+ function prevIntervalRepeat(expr, tz, now) {
1956
+ const nowInTz = now.withTimeZone(tz);
1957
+ let date = nowInTz.toPlainDate();
1958
+ const { interval, unit, from, to, dayFilter } = expr;
1959
+ const stepMinutes = unit === "min" ? interval : interval * 60;
1960
+ const fromMinutes = from.hour * 60 + from.minute;
1961
+ const toMinutes = to.hour * 60 + to.minute;
1962
+ for (let d = 0; d < 8; d++) {
1963
+ if (dayFilter && !matchesDayFilter(date, dayFilter)) {
1964
+ date = date.subtract({ days: 1 });
1965
+ continue;
1966
+ }
1967
+ const nowMinutes = d === 0 ? nowInTz.hour * 60 + nowInTz.minute : toMinutes + 1;
1968
+ const searchUntil = Math.min(nowMinutes, toMinutes);
1969
+ if (searchUntil >= fromMinutes) {
1970
+ const slotsInRange = Math.floor(
1971
+ (searchUntil - fromMinutes) / stepMinutes
1972
+ );
1973
+ let lastSlotMinutes = fromMinutes + slotsInRange * stepMinutes;
1974
+ if (d === 0 && lastSlotMinutes >= nowMinutes) {
1975
+ lastSlotMinutes -= stepMinutes;
1976
+ }
1977
+ if (lastSlotMinutes >= fromMinutes) {
1978
+ const h = Math.floor(lastSlotMinutes / 60);
1979
+ const m = lastSlotMinutes % 60;
1980
+ return atTimeOnDate(date, toPlainTime({ hour: h, minute: m }), tz);
1981
+ }
1982
+ }
1983
+ date = date.subtract({ days: 1 });
1984
+ }
1985
+ return null;
1986
+ }
1987
+ function prevWeekRepeat(expr, tz, anchor, now) {
1988
+ const nowInTz = now.withTimeZone(tz);
1989
+ const date = nowInTz.toPlainDate();
1990
+ const { interval, days, times } = expr;
1991
+ const dayOfWeek = date.dayOfWeek;
1992
+ const currentMonday = date.subtract({ days: dayOfWeek - 1 });
1993
+ const anchorDate = anchor ? import_polyfill.Temporal.PlainDate.from(anchor) : EPOCH_MONDAY;
1994
+ const anchorDayOfWeek = anchorDate.dayOfWeek;
1995
+ const anchorMonday = anchorDate.subtract({ days: anchorDayOfWeek - 1 });
1996
+ const sortedDays = [...days].sort((a, b) => dayToNumber(b) - dayToNumber(a));
1997
+ let checkMonday = currentMonday;
1998
+ for (let w = 0; w < 54; w++) {
1999
+ const weeks = weeksBetween(anchorMonday, checkMonday);
2000
+ if (weeks < 0) {
2001
+ return null;
1140
2002
  }
1141
- let best = null;
1142
- for (const date of dateCandidates) {
1143
- const candidate = earliestFutureAtTimes(date, times, tz, now);
1144
- if (candidate) {
1145
- if (best === null || import_polyfill.Temporal.ZonedDateTime.compare(candidate, best) < 0) {
1146
- best = candidate;
2003
+ if (weeks % interval === 0) {
2004
+ for (const wd of sortedDays) {
2005
+ const dayNum = dayToNumber(wd);
2006
+ const targetDate = checkMonday.add({ days: dayNum - 1 });
2007
+ if (import_polyfill.Temporal.PlainDate.compare(targetDate, date) < 0) {
2008
+ const candidate = latestAtTimes(targetDate, times, tz);
2009
+ if (candidate !== null) return candidate;
2010
+ } else if (import_polyfill.Temporal.PlainDate.compare(targetDate, date) === 0) {
2011
+ const candidate = latestPastAtTimes(targetDate, times, tz, now);
2012
+ if (candidate !== null) return candidate;
1147
2013
  }
1148
2014
  }
1149
2015
  }
1150
- if (best) return best;
1151
- month++;
1152
- if (month > 12) {
1153
- month = 1;
1154
- year++;
1155
- }
2016
+ checkMonday = checkMonday.subtract({ days: interval * 7 });
1156
2017
  }
1157
2018
  return null;
1158
2019
  }
1159
- function nextOrdinalRepeat(interval, ordinal, day, times, tz, anchor, now) {
2020
+ function prevMonthRepeat(expr, tz, anchor, now) {
1160
2021
  const nowInTz = now.withTimeZone(tz);
1161
- let year = nowInTz.year;
1162
- let month = nowInTz.month;
2022
+ const startDate = nowInTz.toPlainDate();
2023
+ const { interval, target, times } = expr;
1163
2024
  const anchorDate = anchor ? import_polyfill.Temporal.PlainDate.from(anchor) : EPOCH_DATE;
2025
+ let year = startDate.year;
2026
+ let month = startDate.month;
1164
2027
  const maxIter = interval > 1 ? 24 * interval : 24;
1165
2028
  for (let i = 0; i < maxIter; i++) {
1166
2029
  if (interval > 1) {
1167
- const cur = import_polyfill.Temporal.PlainDate.from({ year, month, day: 1 });
1168
- const monthOffset = monthsBetweenYM(anchorDate, cur);
1169
- if (monthOffset < 0 || euclideanMod(monthOffset, interval) !== 0) {
1170
- month++;
1171
- if (month > 12) {
1172
- month = 1;
1173
- year++;
1174
- }
2030
+ const monthOffset = monthsBetweenYM(
2031
+ anchorDate,
2032
+ import_polyfill.Temporal.PlainDate.from({ year, month, day: 1 })
2033
+ );
2034
+ if (monthOffset < 0 || monthOffset % interval !== 0) {
2035
+ ({ year, month } = prevMonth(year, month));
1175
2036
  continue;
1176
2037
  }
1177
2038
  }
1178
- let targetDate;
1179
- if (ordinal === "last") {
1180
- targetDate = lastWeekdayInMonth(year, month, day);
1181
- } else {
1182
- targetDate = nthWeekdayOfMonth(year, month, day, ordinalToN(ordinal));
1183
- }
1184
- if (targetDate) {
1185
- const candidate = earliestFutureAtTimes(targetDate, times, tz, now);
1186
- if (candidate) return candidate;
1187
- }
1188
- month++;
1189
- if (month > 12) {
1190
- month = 1;
1191
- year++;
2039
+ const targetDates = getMonthTargetDates(year, month, target);
2040
+ for (const d of targetDates.sort(
2041
+ (a, b) => import_polyfill.Temporal.PlainDate.compare(b, a)
2042
+ )) {
2043
+ if (import_polyfill.Temporal.PlainDate.compare(d, startDate) > 0) continue;
2044
+ if (import_polyfill.Temporal.PlainDate.compare(d, startDate) === 0) {
2045
+ const candidate = latestPastAtTimes(d, times, tz, now);
2046
+ if (candidate !== null) return candidate;
2047
+ } else {
2048
+ const candidate = latestAtTimes(d, times, tz);
2049
+ if (candidate !== null) return candidate;
2050
+ }
1192
2051
  }
2052
+ ({ year, month } = prevMonth(year, month));
1193
2053
  }
1194
2054
  return null;
1195
2055
  }
1196
- function nextSingleDate(dateSpec, times, tz, now) {
2056
+ function prevSingleDate(expr, tz, now) {
1197
2057
  const nowInTz = now.withTimeZone(tz);
2058
+ const nowDate = nowInTz.toPlainDate();
2059
+ const { date: dateSpec, times } = expr;
2060
+ let targetDate;
1198
2061
  if (dateSpec.type === "iso") {
1199
- const date = import_polyfill.Temporal.PlainDate.from(dateSpec.date);
1200
- return earliestFutureAtTimes(date, times, tz, now);
1201
- }
1202
- if (dateSpec.type === "named") {
1203
- const startYear = nowInTz.year;
1204
- for (let y = 0; y < 8; y++) {
1205
- const year = startYear + y;
1206
- try {
1207
- const date = import_polyfill.Temporal.PlainDate.from(
1208
- {
1209
- year,
1210
- month: monthNumber(dateSpec.month),
1211
- day: dateSpec.day
1212
- },
1213
- { overflow: "reject" }
1214
- );
1215
- const candidate = earliestFutureAtTimes(date, times, tz, now);
1216
- if (candidate) return candidate;
1217
- } catch {
1218
- }
2062
+ targetDate = import_polyfill.Temporal.PlainDate.from(dateSpec.date);
2063
+ if (import_polyfill.Temporal.PlainDate.compare(targetDate, nowDate) > 0) {
2064
+ return null;
2065
+ }
2066
+ if (import_polyfill.Temporal.PlainDate.compare(targetDate, nowDate) === 0) {
2067
+ return latestPastAtTimes(targetDate, times, tz, now);
2068
+ }
2069
+ return latestAtTimes(targetDate, times, tz);
2070
+ } else {
2071
+ const { month, day } = dateSpec;
2072
+ const monthNum = monthNumber(month);
2073
+ const thisYear = import_polyfill.Temporal.PlainDate.from({
2074
+ year: nowDate.year,
2075
+ month: monthNum,
2076
+ day
2077
+ });
2078
+ const lastYear = import_polyfill.Temporal.PlainDate.from({
2079
+ year: nowDate.year - 1,
2080
+ month: monthNum,
2081
+ day
2082
+ });
2083
+ if (import_polyfill.Temporal.PlainDate.compare(thisYear, nowDate) < 0) {
2084
+ targetDate = thisYear;
2085
+ } else if (import_polyfill.Temporal.PlainDate.compare(thisYear, nowDate) === 0) {
2086
+ const candidate = latestPastAtTimes(thisYear, times, tz, now);
2087
+ if (candidate !== null) return candidate;
2088
+ targetDate = lastYear;
2089
+ } else {
2090
+ targetDate = lastYear;
1219
2091
  }
1220
- return null;
2092
+ return latestAtTimes(targetDate, times, tz);
1221
2093
  }
1222
- return null;
1223
2094
  }
1224
- function nextYearRepeat(interval, target, times, tz, anchor, now) {
2095
+ function prevYearRepeat(expr, tz, anchor, now) {
1225
2096
  const nowInTz = now.withTimeZone(tz);
1226
- const startYear = nowInTz.year;
2097
+ const startDate = nowInTz.toPlainDate();
2098
+ const startYear = startDate.year;
2099
+ const { interval, target, times } = expr;
1227
2100
  const anchorYear = anchor ? import_polyfill.Temporal.PlainDate.from(anchor).year : EPOCH_DATE.year;
1228
2101
  const maxIter = interval > 1 ? 8 * interval : 8;
1229
2102
  for (let y = 0; y < maxIter; y++) {
1230
- const year = startYear + y;
2103
+ const year = startYear - y;
1231
2104
  if (interval > 1) {
1232
2105
  const yearOffset = year - anchorYear;
1233
- if (yearOffset < 0 || euclideanMod(yearOffset, interval) !== 0) {
2106
+ if (yearOffset < 0 || yearOffset % interval !== 0) {
1234
2107
  continue;
1235
2108
  }
1236
2109
  }
1237
- let targetDate = null;
1238
- switch (target.type) {
1239
- case "date":
1240
- try {
1241
- targetDate = import_polyfill.Temporal.PlainDate.from(
1242
- {
1243
- year,
1244
- month: monthNumber(target.month),
1245
- day: target.day
1246
- },
1247
- { overflow: "reject" }
1248
- );
1249
- } catch {
1250
- continue;
1251
- }
1252
- break;
1253
- case "ordinalWeekday":
1254
- if (target.ordinal === "last") {
1255
- targetDate = lastWeekdayInMonth(
1256
- year,
1257
- monthNumber(target.month),
1258
- target.weekday
1259
- );
1260
- } else {
1261
- targetDate = nthWeekdayOfMonth(
1262
- year,
1263
- monthNumber(target.month),
1264
- target.weekday,
1265
- ordinalToN(target.ordinal)
1266
- );
1267
- }
1268
- break;
1269
- case "dayOfMonth":
2110
+ const targetDate = getYearTargetDate(year, target);
2111
+ if (targetDate !== null) {
2112
+ if (import_polyfill.Temporal.PlainDate.compare(targetDate, startDate) > 0) {
2113
+ continue;
2114
+ }
2115
+ if (import_polyfill.Temporal.PlainDate.compare(targetDate, startDate) === 0) {
2116
+ const candidate = latestPastAtTimes(targetDate, times, tz, now);
2117
+ if (candidate !== null) return candidate;
2118
+ } else {
2119
+ const candidate = latestAtTimes(targetDate, times, tz);
2120
+ if (candidate !== null) return candidate;
2121
+ }
2122
+ }
2123
+ }
2124
+ return null;
2125
+ }
2126
+ function latestPastAtTimes(date, times, tz, now) {
2127
+ const sortedTimes = [...times].sort(
2128
+ (a, b) => b.hour * 60 + b.minute - (a.hour * 60 + a.minute)
2129
+ );
2130
+ for (const tod of sortedTimes) {
2131
+ const t = toPlainTime(tod);
2132
+ const candidate = atTimeOnDate(date, t, tz);
2133
+ if (import_polyfill.Temporal.ZonedDateTime.compare(candidate, now) < 0) {
2134
+ return candidate;
2135
+ }
2136
+ }
2137
+ return null;
2138
+ }
2139
+ function latestAtTimes(date, times, tz) {
2140
+ const sortedTimes = [...times].sort(
2141
+ (a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute)
2142
+ );
2143
+ if (sortedTimes.length === 0) return null;
2144
+ const latest = sortedTimes[sortedTimes.length - 1];
2145
+ return atTimeOnDate(date, toPlainTime(latest), tz);
2146
+ }
2147
+ function prevMonth(year, month) {
2148
+ if (month === 1) {
2149
+ return { year: year - 1, month: 12 };
2150
+ }
2151
+ return { year, month: month - 1 };
2152
+ }
2153
+ function prevDuringMonth(date, during) {
2154
+ let { year, month } = prevMonth(date.year, date.month);
2155
+ for (let i = 0; i < 12; i++) {
2156
+ const monthName = numberToMonthName(month);
2157
+ if (during.includes(monthName)) {
2158
+ return lastDayOfMonth(year, month);
2159
+ }
2160
+ ({ year, month } = prevMonth(year, month));
2161
+ }
2162
+ return date.subtract({ days: 1 });
2163
+ }
2164
+ function numberToMonthName(n) {
2165
+ const names = [
2166
+ "jan",
2167
+ "feb",
2168
+ "mar",
2169
+ "apr",
2170
+ "may",
2171
+ "jun",
2172
+ "jul",
2173
+ "aug",
2174
+ "sep",
2175
+ "oct",
2176
+ "nov",
2177
+ "dec"
2178
+ ];
2179
+ return names[n - 1];
2180
+ }
2181
+ function getMonthTargetDates(year, month, target) {
2182
+ switch (target.type) {
2183
+ case "days": {
2184
+ const expanded = expandMonthTarget(target);
2185
+ return expanded.map((d) => {
1270
2186
  try {
1271
- targetDate = import_polyfill.Temporal.PlainDate.from(
1272
- {
1273
- year,
1274
- month: monthNumber(target.month),
1275
- day: target.day
1276
- },
1277
- { overflow: "reject" }
1278
- );
2187
+ return import_polyfill.Temporal.PlainDate.from({ year, month, day: d });
1279
2188
  } catch {
1280
- continue;
2189
+ return null;
1281
2190
  }
1282
- break;
1283
- case "lastWeekday":
1284
- targetDate = lastWeekdayOfMonth(year, monthNumber(target.month));
1285
- break;
2191
+ }).filter((d) => d !== null);
1286
2192
  }
1287
- if (targetDate) {
1288
- const candidate = earliestFutureAtTimes(targetDate, times, tz, now);
1289
- if (candidate) return candidate;
2193
+ case "lastDay":
2194
+ return [lastDayOfMonth(year, month)];
2195
+ case "lastWeekday":
2196
+ return [lastWeekdayOfMonth(year, month)];
2197
+ case "nearestWeekday": {
2198
+ const d = nearestWeekday(year, month, target.day, target.direction);
2199
+ return d ? [d] : [];
2200
+ }
2201
+ case "ordinalWeekday": {
2202
+ const d = getOrdinalWeekday(year, month, target.ordinal, target.weekday);
2203
+ return d ? [d] : [];
1290
2204
  }
2205
+ default:
2206
+ return [];
2207
+ }
2208
+ }
2209
+ function getYearTargetDate(year, target) {
2210
+ switch (target.type) {
2211
+ case "date": {
2212
+ const monthNum = monthNumber(target.month);
2213
+ try {
2214
+ return import_polyfill.Temporal.PlainDate.from({
2215
+ year,
2216
+ month: monthNum,
2217
+ day: target.day
2218
+ });
2219
+ } catch {
2220
+ return null;
2221
+ }
2222
+ }
2223
+ case "ordinalWeekday": {
2224
+ const monthNum = monthNumber(target.month);
2225
+ return getOrdinalWeekday(year, monthNum, target.ordinal, target.weekday);
2226
+ }
2227
+ case "dayOfMonth": {
2228
+ const monthNum = monthNumber(target.month);
2229
+ try {
2230
+ return import_polyfill.Temporal.PlainDate.from({
2231
+ year,
2232
+ month: monthNum,
2233
+ day: target.day
2234
+ });
2235
+ } catch {
2236
+ return null;
2237
+ }
2238
+ }
2239
+ case "lastWeekday": {
2240
+ const monthNum = monthNumber(target.month);
2241
+ return lastWeekdayOfMonth(year, monthNum);
2242
+ }
2243
+ default:
2244
+ return null;
2245
+ }
2246
+ }
2247
+ function getOrdinalWeekday(year, month, ordinal, day) {
2248
+ if (ordinal === "last") {
2249
+ return lastWeekdayInMonth(year, month, day);
2250
+ }
2251
+ return nthWeekdayOfMonth(year, month, day, ordinalToN(ordinal));
2252
+ }
2253
+ function dayToNumber(day) {
2254
+ const map = {
2255
+ monday: 1,
2256
+ tuesday: 2,
2257
+ wednesday: 3,
2258
+ thursday: 4,
2259
+ friday: 5,
2260
+ saturday: 6,
2261
+ sunday: 7
2262
+ };
2263
+ return map[day];
2264
+ }
2265
+ function* occurrences(schedule, from) {
2266
+ let current = from;
2267
+ for (; ; ) {
2268
+ const next = nextFrom(schedule, current);
2269
+ if (next === null) return;
2270
+ current = next.add({ minutes: 1 });
2271
+ yield next;
2272
+ }
2273
+ }
2274
+ function* between(schedule, from, to) {
2275
+ for (const dt of occurrences(schedule, from)) {
2276
+ if (import_polyfill.Temporal.ZonedDateTime.compare(dt, to) > 0) return;
2277
+ yield dt;
1291
2278
  }
1292
- return null;
1293
2279
  }
1294
2280
 
1295
2281
  // src/lexer.ts
@@ -1459,6 +2445,9 @@ var KEYWORD_MAP = {
1459
2445
  during: { type: "during" },
1460
2446
  year: { type: "year" },
1461
2447
  years: { type: "year" },
2448
+ nearest: { type: "nearest" },
2449
+ next: { type: "next" },
2450
+ previous: { type: "previous" },
1462
2451
  day: { type: "day" },
1463
2452
  days: { type: "day" },
1464
2453
  weekday: { type: "weekday" },
@@ -1594,13 +2583,8 @@ var Parser = class {
1594
2583
  } else if (kind?.type === "on") {
1595
2584
  this.advance();
1596
2585
  expr = this.parseOn();
1597
- } else if (kind?.type === "ordinal" || kind?.type === "last") {
1598
- expr = this.parseOrdinalRepeat();
1599
2586
  } else {
1600
- throw this.error(
1601
- "expected 'every', 'on', or an ordinal (first, second, ...)",
1602
- span
1603
- );
2587
+ throw this.error("expected 'every' or 'on'", span);
1604
2588
  }
1605
2589
  return this.parseTrailingClauses(expr);
1606
2590
  }
@@ -1618,7 +2602,9 @@ var Parser = class {
1618
2602
  this.advance();
1619
2603
  const k = this.peekKind();
1620
2604
  if (k?.type === "isoDate") {
1621
- schedule.anchor = k.date;
2605
+ const startDate = k.date;
2606
+ this.validateIsoDate(startDate);
2607
+ schedule.anchor = startDate;
1622
2608
  this.advance();
1623
2609
  } else {
1624
2610
  throw this.error(
@@ -1655,6 +2641,7 @@ var Parser = class {
1655
2641
  const k = this.peekKind();
1656
2642
  if (k?.type === "isoDate") {
1657
2643
  const date = k.date;
2644
+ this.validateIsoDate(date);
1658
2645
  this.advance();
1659
2646
  return { type: "iso", date };
1660
2647
  }
@@ -1662,10 +2649,13 @@ var Parser = class {
1662
2649
  const month = parseMonthName(
1663
2650
  k.name
1664
2651
  );
2652
+ if (!month) throw this.error("invalid month name", this.currentSpan());
1665
2653
  this.advance();
2654
+ const dayPos = this.currentSpan().start;
1666
2655
  const day = this.parseDayNumber(
1667
2656
  "expected day number after month name in exception"
1668
2657
  );
2658
+ this.validateNamedDate(month, day, dayPos);
1669
2659
  return { type: "named", month, day };
1670
2660
  }
1671
2661
  throw this.error(
@@ -1677,6 +2667,7 @@ var Parser = class {
1677
2667
  const k = this.peekKind();
1678
2668
  if (k?.type === "isoDate") {
1679
2669
  const date = k.date;
2670
+ this.validateIsoDate(date);
1680
2671
  this.advance();
1681
2672
  return { type: "iso", date };
1682
2673
  }
@@ -1684,10 +2675,13 @@ var Parser = class {
1684
2675
  const month = parseMonthName(
1685
2676
  k.name
1686
2677
  );
2678
+ if (!month) throw this.error("invalid month name", this.currentSpan());
1687
2679
  this.advance();
2680
+ const dayPos = this.currentSpan().start;
1688
2681
  const day = this.parseDayNumber(
1689
2682
  "expected day number after month name in until"
1690
2683
  );
2684
+ this.validateNamedDate(month, day, dayPos);
1691
2685
  return { type: "named", month, day };
1692
2686
  }
1693
2687
  throw this.error(
@@ -1699,11 +2693,23 @@ var Parser = class {
1699
2693
  const k = this.peekKind();
1700
2694
  if (k?.type === "number") {
1701
2695
  const n = k.value;
2696
+ if (n < 1 || n > 31) {
2697
+ throw this.error(
2698
+ `invalid day number ${n} (must be 1-31)`,
2699
+ this.currentSpan()
2700
+ );
2701
+ }
1702
2702
  this.advance();
1703
2703
  return n;
1704
2704
  }
1705
2705
  if (k?.type === "ordinalNumber") {
1706
2706
  const n = k.value;
2707
+ if (n < 1 || n > 31) {
2708
+ throw this.error(
2709
+ `invalid day number ${n} (must be 1-31)`,
2710
+ this.currentSpan()
2711
+ );
2712
+ }
1707
2713
  this.advance();
1708
2714
  return n;
1709
2715
  }
@@ -1713,6 +2719,7 @@ var Parser = class {
1713
2719
  parseEvery() {
1714
2720
  if (!this.peek()) throw this.errorAtEnd("expected repeater");
1715
2721
  const k = this.peekKind();
2722
+ if (!k) throw this.errorAtEnd("expected repeater");
1716
2723
  if (k.type === "year") {
1717
2724
  this.advance();
1718
2725
  return this.parseYearRepeat(1);
@@ -1755,6 +2762,7 @@ var Parser = class {
1755
2762
  parseNumberRepeat() {
1756
2763
  const span = this.currentSpan();
1757
2764
  const k = this.peekKind();
2765
+ if (!k) throw this.errorAtEnd("expected number");
1758
2766
  const num = k.value;
1759
2767
  if (num === 0) {
1760
2768
  throw this.error("interval must be at least 1", span);
@@ -1786,6 +2794,7 @@ var Parser = class {
1786
2794
  }
1787
2795
  parseIntervalRepeat(interval) {
1788
2796
  const k = this.peekKind();
2797
+ if (!k) throw this.errorAtEnd("expected interval unit");
1789
2798
  const unitStr = k.unit;
1790
2799
  this.advance();
1791
2800
  const unit = unitStr === "min" ? "min" : "hours";
@@ -1821,18 +2830,43 @@ var Parser = class {
1821
2830
  } else if (next?.type === "weekday") {
1822
2831
  this.advance();
1823
2832
  target = { type: "lastWeekday" };
2833
+ } else if (next?.type === "dayName") {
2834
+ const weekday = parseWeekday(
2835
+ next.name
2836
+ );
2837
+ if (!weekday) throw this.error("invalid weekday", this.currentSpan());
2838
+ this.advance();
2839
+ target = { type: "ordinalWeekday", ordinal: "last", weekday };
2840
+ } else {
2841
+ throw this.error(
2842
+ "expected 'day', 'weekday', or day name after 'last'",
2843
+ this.currentSpan()
2844
+ );
2845
+ }
2846
+ } else if (k?.type === "ordinal") {
2847
+ const ordinal = this.parseOrdinalPosition();
2848
+ const next = this.peekKind();
2849
+ if (next?.type === "dayName") {
2850
+ const weekday = parseWeekday(
2851
+ next.name
2852
+ );
2853
+ if (!weekday) throw this.error("invalid weekday", this.currentSpan());
2854
+ this.advance();
2855
+ target = { type: "ordinalWeekday", ordinal, weekday };
1824
2856
  } else {
1825
2857
  throw this.error(
1826
- "expected 'day' or 'weekday' after 'last'",
2858
+ "expected day name after ordinal in month expression",
1827
2859
  this.currentSpan()
1828
2860
  );
1829
2861
  }
1830
2862
  } else if (k?.type === "ordinalNumber") {
1831
2863
  const specs = this.parseOrdinalDayList();
1832
2864
  target = { type: "days", specs };
2865
+ } else if (k?.type === "next" || k?.type === "previous" || k?.type === "nearest") {
2866
+ target = this.parseNearestWeekdayTarget();
1833
2867
  } else {
1834
2868
  throw this.error(
1835
- "expected ordinal day (1st, 15th) or 'last' after 'the'",
2869
+ "expected ordinal day (1st, 15th), 'last', ordinal weekday, or '[next|previous] nearest' after 'the'",
1836
2870
  this.currentSpan()
1837
2871
  );
1838
2872
  }
@@ -1840,31 +2874,37 @@ var Parser = class {
1840
2874
  const times = this.parseTimeList();
1841
2875
  return { type: "monthRepeat", interval, target, times };
1842
2876
  }
1843
- parseOrdinalRepeat() {
1844
- const ordinal = this.parseOrdinalPosition();
2877
+ // [next|previous] nearest weekday to <day>
2878
+ parseNearestWeekdayTarget() {
2879
+ let direction = null;
1845
2880
  const k = this.peekKind();
1846
- if (k?.type !== "dayName") {
1847
- throw this.error("expected day name after ordinal", this.currentSpan());
2881
+ if (k?.type === "next") {
2882
+ this.advance();
2883
+ direction = "next";
2884
+ } else if (k?.type === "previous") {
2885
+ this.advance();
2886
+ direction = "previous";
1848
2887
  }
1849
- const day = parseWeekday(
1850
- k.name
1851
- );
1852
- this.advance();
1853
- this.consumeKind("'of'", (k2) => k2.type === "of");
1854
- this.consumeKind("'every'", (k2) => k2.type === "every");
1855
- let interval = 1;
1856
- const next = this.peekKind();
1857
- if (next?.type === "number") {
1858
- interval = next.value;
1859
- if (interval === 0) {
1860
- throw this.error("interval must be at least 1", this.currentSpan());
2888
+ this.consumeKind("'nearest'", (k2) => k2.type === "nearest");
2889
+ this.consumeKind("'weekday'", (k2) => k2.type === "weekday");
2890
+ this.consumeKind("'to'", (k2) => k2.type === "to");
2891
+ const day = this.parseOrdinalDayNumber();
2892
+ return { type: "nearestWeekday", day, direction };
2893
+ }
2894
+ parseOrdinalDayNumber() {
2895
+ const k = this.peekKind();
2896
+ if (k?.type === "ordinalNumber") {
2897
+ const d = k.value;
2898
+ if (d < 1 || d > 31) {
2899
+ throw this.error(
2900
+ `invalid day number ${d} (must be 1-31)`,
2901
+ this.currentSpan()
2902
+ );
1861
2903
  }
1862
2904
  this.advance();
2905
+ return d;
1863
2906
  }
1864
- this.consumeKind("'month'", (k2) => k2.type === "month");
1865
- this.consumeKind("'at'", (k2) => k2.type === "at");
1866
- const times = this.parseTimeList();
1867
- return { type: "ordinalRepeat", interval, ordinal, day, times };
2907
+ throw this.error("expected ordinal day number", this.currentSpan());
1868
2908
  }
1869
2909
  parseYearRepeat(interval) {
1870
2910
  this.consumeKind("'on'", (k2) => k2.type === "on");
@@ -1877,8 +2917,11 @@ var Parser = class {
1877
2917
  const month = parseMonthName(
1878
2918
  k.name
1879
2919
  );
2920
+ if (!month) throw this.error("invalid month name", this.currentSpan());
1880
2921
  this.advance();
2922
+ const dayPos = this.currentSpan().start;
1881
2923
  const day = this.parseDayNumber("expected day number after month name");
2924
+ this.validateNamedDate(month, day, dayPos);
1882
2925
  target = { type: "date", month, day };
1883
2926
  } else {
1884
2927
  throw this.error(
@@ -1905,6 +2948,7 @@ var Parser = class {
1905
2948
  const weekday = parseWeekday(
1906
2949
  next.name
1907
2950
  );
2951
+ if (!weekday) throw this.error("invalid weekday", this.currentSpan());
1908
2952
  this.advance();
1909
2953
  this.consumeKind("'of'", (k2) => k2.type === "of");
1910
2954
  const month = this.parseMonthNameToken();
@@ -1922,6 +2966,7 @@ var Parser = class {
1922
2966
  const weekday = parseWeekday(
1923
2967
  next.name
1924
2968
  );
2969
+ if (!weekday) throw this.error("invalid weekday", this.currentSpan());
1925
2970
  this.advance();
1926
2971
  this.consumeKind("'of'", (k2) => k2.type === "of");
1927
2972
  const month = this.parseMonthNameToken();
@@ -1934,9 +2979,17 @@ var Parser = class {
1934
2979
  }
1935
2980
  if (k?.type === "ordinalNumber") {
1936
2981
  const day = k.value;
2982
+ if (day < 1 || day > 31) {
2983
+ throw this.error(
2984
+ `invalid day number ${day} (must be 1-31)`,
2985
+ this.currentSpan()
2986
+ );
2987
+ }
2988
+ const dayPos = this.currentSpan().start;
1937
2989
  this.advance();
1938
2990
  this.consumeKind("'of'", (k2) => k2.type === "of");
1939
2991
  const month = this.parseMonthNameToken();
2992
+ this.validateNamedDate(month, day, dayPos);
1940
2993
  return { type: "dayOfMonth", day, month };
1941
2994
  }
1942
2995
  throw this.error(
@@ -1950,6 +3003,7 @@ var Parser = class {
1950
3003
  const month = parseMonthName(
1951
3004
  k.name
1952
3005
  );
3006
+ if (!month) throw this.error("invalid month name", this.currentSpan());
1953
3007
  this.advance();
1954
3008
  return month;
1955
3009
  }
@@ -1978,10 +3032,50 @@ var Parser = class {
1978
3032
  const times = this.parseTimeList();
1979
3033
  return { type: "singleDate", date, times };
1980
3034
  }
3035
+ validateNamedDate(month, day, pos) {
3036
+ const maxDays = {
3037
+ jan: 31,
3038
+ feb: 29,
3039
+ mar: 31,
3040
+ apr: 30,
3041
+ may: 31,
3042
+ jun: 30,
3043
+ jul: 31,
3044
+ aug: 31,
3045
+ sep: 30,
3046
+ oct: 31,
3047
+ nov: 30,
3048
+ dec: 31
3049
+ };
3050
+ const max = maxDays[month];
3051
+ if (day > max) {
3052
+ throw this.error(`invalid day ${day} for ${month} (max ${max})`, {
3053
+ start: pos,
3054
+ end: pos
3055
+ });
3056
+ }
3057
+ }
3058
+ validateIsoDate(dateStr) {
3059
+ const parts = dateStr.split("-");
3060
+ const year = parseInt(parts[0], 10);
3061
+ const month = parseInt(parts[1], 10);
3062
+ const day = parseInt(parts[2], 10);
3063
+ if (month < 1 || month > 12 || day < 1) {
3064
+ throw this.error(`invalid date: ${dateStr}`, this.currentSpan());
3065
+ }
3066
+ const daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
3067
+ if (year % 4 === 0 && year % 100 !== 0 || year % 400 === 0) {
3068
+ daysInMonth[2] = 29;
3069
+ }
3070
+ if (day > daysInMonth[month]) {
3071
+ throw this.error(`invalid date: ${dateStr}`, this.currentSpan());
3072
+ }
3073
+ }
1981
3074
  parseDateTarget() {
1982
3075
  const k = this.peekKind();
1983
3076
  if (k?.type === "isoDate") {
1984
3077
  const date = k.date;
3078
+ this.validateIsoDate(date);
1985
3079
  this.advance();
1986
3080
  return { type: "iso", date };
1987
3081
  }
@@ -1989,8 +3083,11 @@ var Parser = class {
1989
3083
  const month = parseMonthName(
1990
3084
  k.name
1991
3085
  );
3086
+ if (!month) throw this.error("invalid month name", this.currentSpan());
1992
3087
  this.advance();
3088
+ const dayPos = this.currentSpan().start;
1993
3089
  const day = this.parseDayNumber("expected day number after month name");
3090
+ this.validateNamedDate(month, day, dayPos);
1994
3091
  return { type: "named", month, day };
1995
3092
  }
1996
3093
  throw this.error(
@@ -2026,9 +3123,11 @@ var Parser = class {
2026
3123
  if (k?.type !== "dayName") {
2027
3124
  throw this.error("expected day name", this.currentSpan());
2028
3125
  }
2029
- const days = [
2030
- parseWeekday(k.name)
2031
- ];
3126
+ const firstDay = parseWeekday(
3127
+ k.name
3128
+ );
3129
+ if (!firstDay) throw this.error("invalid weekday", this.currentSpan());
3130
+ const days = [firstDay];
2032
3131
  this.advance();
2033
3132
  while (this.peekKind()?.type === "comma") {
2034
3133
  this.advance();
@@ -2036,11 +3135,11 @@ var Parser = class {
2036
3135
  if (next?.type !== "dayName") {
2037
3136
  throw this.error("expected day name after ','", this.currentSpan());
2038
3137
  }
2039
- days.push(
2040
- parseWeekday(
2041
- next.name
2042
- )
3138
+ const day = parseWeekday(
3139
+ next.name
2043
3140
  );
3141
+ if (!day) throw this.error("invalid weekday", this.currentSpan());
3142
+ days.push(day);
2044
3143
  this.advance();
2045
3144
  }
2046
3145
  return days;
@@ -2059,6 +3158,12 @@ var Parser = class {
2059
3158
  throw this.error("expected ordinal day number", this.currentSpan());
2060
3159
  }
2061
3160
  const start = k.value;
3161
+ if (start < 1 || start > 31) {
3162
+ throw this.error(
3163
+ `invalid day number ${start} (must be 1-31)`,
3164
+ this.currentSpan()
3165
+ );
3166
+ }
2062
3167
  this.advance();
2063
3168
  if (this.peekKind()?.type === "to") {
2064
3169
  this.advance();
@@ -2070,7 +3175,19 @@ var Parser = class {
2070
3175
  );
2071
3176
  }
2072
3177
  const end = next.value;
3178
+ if (end < 1 || end > 31) {
3179
+ throw this.error(
3180
+ `invalid day number ${end} (must be 1-31)`,
3181
+ this.currentSpan()
3182
+ );
3183
+ }
2073
3184
  this.advance();
3185
+ if (start > end) {
3186
+ throw this.error(
3187
+ `invalid day range: ${start} to ${end} (start must be <= end)`,
3188
+ this.currentSpan()
3189
+ );
3190
+ }
2074
3191
  return { type: "range", start, end };
2075
3192
  }
2076
3193
  return { type: "single", day: start };
@@ -2151,10 +3268,29 @@ var Schedule = class _Schedule {
2151
3268
  nextNFrom(now, n) {
2152
3269
  return nextNFrom(this.data, now, n);
2153
3270
  }
3271
+ /** Compute the most recent occurrence strictly before `now`. */
3272
+ previousFrom(now) {
3273
+ return previousFrom(this.data, now);
3274
+ }
2154
3275
  /** Check if a datetime matches this schedule. */
2155
3276
  matches(datetime) {
2156
3277
  return matches(this.data, datetime);
2157
3278
  }
3279
+ /**
3280
+ * Returns a lazy iterator of occurrences starting after `from`.
3281
+ * The iterator is unbounded for repeating schedules (will iterate forever unless limited),
3282
+ * but respects the `until` clause if specified in the schedule.
3283
+ */
3284
+ *occurrences(from) {
3285
+ yield* occurrences(this.data, from);
3286
+ }
3287
+ /**
3288
+ * Returns a bounded iterator of occurrences where `from < occurrence <= to`.
3289
+ * The iterator yields occurrences strictly after `from` and up to and including `to`.
3290
+ */
3291
+ *between(from, to) {
3292
+ yield* between(this.data, from, to);
3293
+ }
2158
3294
  /** Convert this schedule to a 5-field cron expression. */
2159
3295
  toCron() {
2160
3296
  return toCron(this.data);