cronli5 0.1.7 → 0.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.
@@ -5,7 +5,7 @@
5
5
 
6
6
  import {arithmeticStep, orderWeekdaysForDisplay} from '../../core/util.js';
7
7
  import {maxClockTimes} from '../../core/specs.js';
8
- import {clockDigits, numeral} from '../../core/format.js';
8
+ import {clockDigits, numeral, pad} from '../../core/format.js';
9
9
  import type {Cronli5Options} from '../../types.js';
10
10
  import type {
11
11
  HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -130,7 +130,16 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
130
130
 
131
131
  // Render an analyzed cron pattern (the IR) as English.
132
132
  function describe(ir: IR, opts: NormalizedOptions): string {
133
- return applyYear(render(ir, ir.plan, opts), ir, opts);
133
+ // A finer leading cadence puts each coarser field in the confinement frame,
134
+ // overriding the per-plan juxtaposed-cadence and duration-frame forms.
135
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
136
+
137
+ // A day union scopes the whole clause by its month, which leads the
138
+ // description ("in June <time> whenever the day is …"); the time/cadence and
139
+ // the trailing condition are already in `body`.
140
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : '';
141
+
142
+ return applyYear(lead + body, ir, opts);
134
143
  }
135
144
 
136
145
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
@@ -337,7 +346,7 @@ function secondsClause(ir: IR, anchor: string,
337
346
 
338
347
  if (shape === 'range') {
339
348
  const bounds = secondField.split('-');
340
- const num = seriesNumber(bounds, opts);
349
+ const num = seriesNumber();
341
350
 
342
351
  return 'every second from ' + num(bounds[0]) +
343
352
  through(opts) + num(bounds[1]) + ' past the ' + anchor;
@@ -460,10 +469,34 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
460
469
  trailingQualifier(ir, opts);
461
470
  }
462
471
 
463
- const lead = plan.form === 'range' ?
464
- minuteRangeLead(ir.pattern.minute, opts) :
465
- // The 'list' form is a minute list, which has segments; an offset/uneven
466
- // step enumerated to that list reads as a stride.
472
+ if (plan.form === 'range') {
473
+ const lead = minuteRangeLead(ir.pattern.minute, opts);
474
+
475
+ if (cadence !== null) {
476
+ return lead + ', ' + cadence + trailingQualifier(ir, opts);
477
+ }
478
+
479
+ // A plain minute range is a cadence, so an hour list confines it with the
480
+ // "during the … hours" idiom — the same reading the seconds-leading
481
+ // sibling and the wildcard-minute form already use — rather than a
482
+ // clock-time "at <times>" list, which reads as discrete fire points. A
483
+ // lone hour is not a list, so it keeps the "at <time>" frame ("…past the
484
+ // hour, at 9 a.m."), never the plural "hours" confinement.
485
+ if (singleHourFire(plan.times)) {
486
+ return lead + ', at ' +
487
+ hourTimesFromPlan(ir, plan.times, true, opts) +
488
+ trailingQualifier(ir, opts);
489
+ }
490
+
491
+ return lead + ' during the ' +
492
+ hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
493
+ trailingQualifier(ir, opts);
494
+ }
495
+
496
+ // The 'list' form is a minute list, which has segments; an offset/uneven
497
+ // step enumerated to that list reads as a stride. A list is a set of
498
+ // discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
499
+ const lead =
467
500
  strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
468
501
  listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
469
502
  'minute', 'hour', opts);
@@ -532,7 +565,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
532
565
  function minuteRangeLead(minuteField: string,
533
566
  opts: NormalizedOptions): string {
534
567
  const bounds = minuteField.split('-');
535
- const num = seriesNumber(bounds, opts);
568
+ const num = seriesNumber();
536
569
 
537
570
  return 'every minute from ' + num(bounds[0]) + through(opts) +
538
571
  num(bounds[1]) + ' past the hour';
@@ -609,13 +642,35 @@ function boundedWindow(plan: PlanOf<'hourRange'>):
609
642
  return {from: plan.from, last, to: plan.to};
610
643
  }
611
644
 
612
- // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.". Windows
613
- // open at the top of the first hour and close at the minute field's last
614
- // fire within the final hour.
645
+ // A contiguous hour range as a window phrase. The default English dialect
646
+ // reads a MULTI-hour range as an up-to-but-not-including window "from 9 a.m.
647
+ // until 6 p.m." (the close is the top of the hour after the last, the sense
648
+ // English uses for time windows: 9-17 runs until 6 p.m.); 23 wraps to
649
+ // midnight. Every other dialect (and the compact `short` form) keeps the
650
+ // "through <last fire>" span, closing on the minute field's last fire within
651
+ // the final hour. A single-hour sub-hour window (`from === to`, e.g. */15 9
652
+ // firing 9:00 through 9:45) is NOT a multi-hour range: its close is a real
653
+ // fire inside the hour, so it always keeps "through" — naming "until 10 a.m."
654
+ // would overstate the span past the last fire.
655
+ function rangeWindow(from: number, to: number, throughMinute: number | string,
656
+ opts: NormalizedOptions): string {
657
+ const open = 'from ' + getTime({hour: from, minute: 0}, opts);
658
+
659
+ if (opts.style.untilWindow && !opts.short && from !== to) {
660
+ return open + ' until ' +
661
+ getTime({hour: (to + 1) % 24, minute: 0}, opts);
662
+ }
663
+
664
+ return open + through(opts) +
665
+ getTime({hour: to, minute: throughMinute}, opts);
666
+ }
667
+
668
+ // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m." (or "from 9 a.m.
669
+ // until 6 p.m." in the default dialect). Windows open at the top of the first
670
+ // hour and close at the minute field's last fire within the final hour.
615
671
  function hourWindow(window: {from: number; to: number; last: number},
616
672
  opts: NormalizedOptions): string {
617
- return 'from ' + getTime({hour: window.from, minute: 0}, opts) +
618
- through(opts) + getTime({hour: window.to, minute: window.last}, opts);
673
+ return rangeWindow(window.from, window.to, window.last, opts);
619
674
  }
620
675
 
621
676
  // Expand a discrete set of hours and minutes into clock times prefixed by
@@ -645,7 +700,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
645
700
  }, opts);
646
701
  });
647
702
 
648
- return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts);
703
+ return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts) +
704
+ dayUnionTrail(ir, opts);
705
+ }
706
+
707
+ // The trailing day-union condition for a clock-time form (which leads with its
708
+ // time, not a day qualifier), or an empty string when the pattern is not a day
709
+ // union. The cadence renderers carry this through `trailingQualifier` instead.
710
+ function dayUnionTrail(ir: IR, opts: NormalizedOptions): string {
711
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : '';
649
712
  }
650
713
 
651
714
  // Compact form for a clock-time set past the enumeration cap. A single
@@ -679,7 +742,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
679
742
  const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
680
743
 
681
744
  return interpretDayQualifier(ir, opts) + 'at ' +
682
- hourSegmentTimes(ir, fold, true, opts);
745
+ hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
683
746
  }
684
747
 
685
748
  const minuteLead =
@@ -706,39 +769,330 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
706
769
 
707
770
  // A folded hour field that includes a contiguous range reads with the
708
771
  // hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
709
- // past the hour"), each range as a "from X through Y" window, and any
710
- // non-contiguous hours appended as "and at Z".
772
+ // past the hour"), each range as a window, and any non-contiguous hour
773
+ // appended by `outlierTail` (the default until-window form reads "plus Z";
774
+ // every other dialect keeps "and at Z").
711
775
  function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
712
776
  opts: NormalizedOptions): string {
713
777
  const minute = plan.minute;
714
778
  const windows: string[] = [];
715
- const singles: number[] = [];
779
+ const outliers = collectHourOutliers(ir);
780
+ const times = outliers.hours.map(function time(hour) {
781
+ return getTime({hour, minute}, opts);
782
+ });
716
783
 
717
784
  // Reached only via the fold branch under discrete hours, which have
718
785
  // segments.
719
786
  ir.analyses.segments.hour!.forEach(function classify(segment) {
720
787
  if (segment.kind === 'range') {
721
- windows.push('from ' + getTime({hour: segment.bounds[0], minute: 0},
722
- opts) + through(opts) +
723
- getTime({hour: segment.bounds[1], minute}, opts));
788
+ windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1],
789
+ minute, opts));
724
790
  }
725
- else if (segment.kind === 'step') {
726
- singles.push(...segment.fires);
791
+ });
792
+
793
+ const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
794
+
795
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
796
+ }
797
+
798
+ // The hours outside a contiguous run — every non-range segment's values — and
799
+ // whether they are all STRAY single values (no step fires). A step beside a run
800
+ // contributes a whole cadence's worth of fires, not a lone outlier, so the
801
+ // "plus" idiom does not fit and the additive list keeps "and at".
802
+ function collectHourOutliers(ir: IR):
803
+ {hours: number[]; pureStrays: boolean} {
804
+ const hours: number[] = [];
805
+ let pureStrays = true;
806
+
807
+ // Reached only under discrete hours, which carry segments.
808
+ ir.analyses.segments.hour!.forEach(function classify(segment) {
809
+ if (segment.kind === 'step') {
810
+ hours.push(...segment.fires);
811
+ pureStrays = false;
727
812
  }
728
- else {
729
- singles.push(+segment.value);
813
+ else if (segment.kind !== 'range') {
814
+ hours.push(+segment.value);
730
815
  }
731
816
  });
732
817
 
733
- let phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
818
+ return {hours, pureStrays};
819
+ }
734
820
 
735
- if (singles.length) {
736
- phrase += ' and at ' + joinList(singles.map(function time(hour) {
737
- return getTime({hour, minute}, opts);
738
- }), opts);
821
+ // Join the outlier hour times that follow a contiguous-run window. When the run
822
+ // rendered as the leading until-window ("from 9 a.m. until 9 p.m.") and the
823
+ // outlier is a single stray value, it reads "plus 10 p.m." — an additive idiom
824
+ // for the one hour that breaks the run. A step beside the run is a full cadence
825
+ // of fires, not a lone outlier, so it keeps the enumerating "and at"; so does
826
+ // every other dialect (and the compact `short` form), which renders the run as
827
+ // a "through <last fire>" span rather than the until-window.
828
+ function outlierTail(times: string[], pureStrays: boolean,
829
+ opts: NormalizedOptions): string {
830
+ if (!times.length) {
831
+ return '';
739
832
  }
740
833
 
741
- return phrase;
834
+ const connector = pureStrays && opts.style.untilWindow && !opts.short ?
835
+ ' plus ' :
836
+ ' and at ';
837
+
838
+ return connector + joinList(times, opts);
839
+ }
840
+
841
+ // --- Confinement frame. ---
842
+ //
843
+ // Under a finer LEADING CADENCE — the finest restricted field spoken as a
844
+ // recurrence ("every second", "every 15 seconds", "every minute", "every two
845
+ // minutes") — each COARSER restricted field reads as a confinement, not a
846
+ // juxtaposed cadence: "every second during minute :00 of every hour", "every
847
+ // second of the midnight hour", "every two minutes from midnight until 1 a.m.".
848
+ // A redundant unrestricted finer field drops ("every second" already spans all
849
+ // minutes, so a wildcard minute is not stated). The leading field is the
850
+ // seconds when it is a wildcard or clean step; otherwise the minute, when the
851
+ // second is a plain :00 and the minute is a wildcard or clean step. A single,
852
+ // range, or list lead is a clock-point form ("at 30 seconds past the minute"),
853
+ // not a cadence, and is left to the existing renderers.
854
+
855
+ // Whether a field token is a wildcard or a clean step (`*/n`) — the two shapes
856
+ // that read as a leading cadence. A bounded step (`a-b/n`) is a windowed set,
857
+ // not a clean day/hour-spanning cadence.
858
+ function isCadenceField(token: string): boolean {
859
+ return token === '*' ||
860
+ token.startsWith('*/') && token.indexOf('-') === -1;
861
+ }
862
+
863
+ // The leading cadence and whether the second is the leading field, or null when
864
+ // the pattern has no cadence lead (the finest restricted field is a clock-point
865
+ // single/range/list). The seconds lead when restricted as a cadence; otherwise
866
+ // the minute leads when the second is a plain :00 and the minute is a cadence.
867
+ function leadingCadence(ir: IR, opts: NormalizedOptions):
868
+ {text: string; secondLead: boolean} | null {
869
+ const {second, minute} = ir.pattern;
870
+
871
+ if (isCadenceField(second)) {
872
+ return {secondLead: true, text: secondsClause(ir, 'minute', opts)};
873
+ }
874
+
875
+ if (second === '0' && isCadenceField(minute)) {
876
+ const text = minute === '*' ?
877
+ 'every minute' :
878
+ // A clean minute step's first segment is a step segment.
879
+ stepCycle60(ir.analyses.segments.minute![0] as StepSegment,
880
+ 'minute', 'hour', opts);
881
+
882
+ return {secondLead: false, text};
883
+ }
884
+
885
+ return null;
886
+ }
887
+
888
+ // A pinned minute (single/range/list) under a seconds lead reads as a
889
+ // confinement: "during minute :NN", "during minutes :NN through :MM", "during
890
+ // minutes :NN and :MM". A clean minute step reads "of every other minute". A
891
+ // wildcard minute is redundant under the seconds cadence and drops (empty).
892
+ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
893
+ const minute = ir.pattern.minute;
894
+
895
+ if (minute === '*') {
896
+ return '';
897
+ }
898
+
899
+ if (isCadenceField(minute)) {
900
+ // The gate admits only the `*/2` "every other minute" step here; other
901
+ // minute steps defer to the existing renderer.
902
+ return ' of every other minute';
903
+ }
904
+
905
+ // A minute single/range/list under the seconds lead. The minute reads as a
906
+ // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
907
+ // the minute-lead clock-point form).
908
+ const segments = ir.analyses.segments.minute!;
909
+
910
+ if (ir.shapes.minute === 'single') {
911
+ return ' during minute :' + pad(minute);
912
+ }
913
+
914
+ if (ir.shapes.minute === 'range') {
915
+ const bounds = minute.split('-');
916
+
917
+ return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
918
+ pad(bounds[1]);
919
+ }
920
+
921
+ const values = segmentWords(segments, opts).map(function colon(word) {
922
+ return ':' + pad(word);
923
+ });
924
+
925
+ return ' during minutes ' + joinList(values, opts);
926
+ }
927
+
928
+ // A restricted hour under a finer cadence reads as a confinement. The form
929
+ // depends on the nearest stated finer field: a stepped minute makes a single
930
+ // hour a span ("from midnight until 1 a.m."); a pinned minute makes it a clock
931
+ // point ("at midnight"); a wildcard/absent minute makes it the hour itself
932
+ // ("of the midnight hour"). A clean hour step is "of every other hour"; a range
933
+ // reuses the until-window; a list or stepped range reads "during the … hours".
934
+ // A wildcard hour drops (empty).
935
+ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
936
+ const hour = ir.pattern.hour;
937
+
938
+ if (hour === '*') {
939
+ // A pinned minute confinement ("during minute :00") repeats across every
940
+ // hour, so the hour is named as the unit of recurrence; a stepped minute
941
+ // ("of every other minute") or absent minute already implies all hours.
942
+ const minutePinned = ir.pattern.minute !== '*' &&
943
+ !isCadenceField(ir.pattern.minute);
944
+
945
+ return minutePinned ? ' of every hour' : '';
946
+ }
947
+
948
+ if (isCadenceField(hour)) {
949
+ return hour === '*/2' ? ' of every other hour' : '';
950
+ }
951
+
952
+ if (ir.shapes.hour === 'single') {
953
+ const h = +hour;
954
+
955
+ if (ir.shapes.minute === 'step') {
956
+ return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
957
+ getTime({hour: (h + 1) % 24, minute: 0}, opts);
958
+ }
959
+
960
+ // A pinned minute confinement already named the minute, so the hour reads
961
+ // as a plain clock point; a wildcard or absent minute makes the hour the
962
+ // unit of recurrence ("of the midnight hour").
963
+ if (ir.pattern.minute !== '*' && !isCadenceField(ir.pattern.minute)) {
964
+ return ' at ' + getTime({hour: h, minute: 0}, opts);
965
+ }
966
+
967
+ return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
968
+ }
969
+
970
+ if (ir.shapes.hour === 'range') {
971
+ const bounds = hour.split('-');
972
+
973
+ return ' ' + rangeWindow(+bounds[0], +bounds[1], 0, opts);
974
+ }
975
+
976
+ // An hour list or stepped range reads "during the <times> hours".
977
+ return ' during the ' +
978
+ hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) + ' hours';
979
+ }
980
+
981
+ // Whether the hour field reads as a contiguous window — a real range whose
982
+ // close depends on the finer field's last fire. A finer STEP cadence does not
983
+ // fill the closing hour ("from 9 a.m. until 5:45 p.m."), so that window is left
984
+ // to the existing windowing renderer rather than the confinement frame, which
985
+ // closes on the top of the next hour ("until 6 p.m.").
986
+ function isContiguousHourRange(ir: IR): boolean {
987
+ return ir.shapes.hour === 'range';
988
+ }
989
+
990
+ // Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
991
+ // `*/n`, an offset `m/n`, or a uneven step — reads as a cadence ("every three
992
+ // hours from 2 a.m."), and only the `*/2` form has a blessed confinement idiom
993
+ // ("of every other hour"), so other open steps defer. A BOUNDED stepped range
994
+ // (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the confinement
995
+ // frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
996
+ function confinableHour(ir: IR): boolean {
997
+ if (ir.shapes.hour !== 'step') {
998
+ return true;
999
+ }
1000
+
1001
+ // Reached only under a stepped hour, whose first segment is a step segment.
1002
+ const segment = ir.analyses.segments.hour![0] as StepSegment;
1003
+
1004
+ return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
1005
+ }
1006
+
1007
+ // Whether a minute list is really a stride the existing renderer speaks as a
1008
+ // cadence ("every two minutes from 3 through 59"): such a progression is not a
1009
+ // short explicit ":NN" confinement, so it defers.
1010
+ function isMinuteStride(ir: IR): boolean {
1011
+ if (ir.shapes.minute !== 'list') {
1012
+ return false;
1013
+ }
1014
+
1015
+ const values = singleValues(ir.analyses.segments.minute!);
1016
+
1017
+ return values !== null && arithmeticStep(values) !== null;
1018
+ }
1019
+
1020
+ // Whether the pattern is in the panel-blessed confinement shape-set. The frame
1021
+ // covers a finer leading cadence (seconds, or minute under a :00 second) with
1022
+ // each coarser field as a confinement; shapes outside the blessed set defer to
1023
+ // the existing renderers, which already produce the blessed phrasing for them.
1024
+ function confinementEligible(ir: IR,
1025
+ lead: {secondLead: boolean}): boolean {
1026
+ const {minute, hour} = ir.pattern;
1027
+ const minuteStep = isCadenceField(minute) && minute !== '*';
1028
+
1029
+ // A non-`*/2` hour stride keeps the existing cadence form.
1030
+ if (!confinableHour(ir)) {
1031
+ return false;
1032
+ }
1033
+
1034
+ if (lead.secondLead) {
1035
+ // A minute STEP is blessed only as the `*/2` "every other minute" idiom,
1036
+ // and only where it fills the coarser field: a contiguous hour range or a
1037
+ // single hour both close on the minute's real last fire, which the
1038
+ // windowing renderer already speaks. The `*/2` step fills both, so it keeps
1039
+ // the "of every other minute" confinement; other steps defer entirely.
1040
+ if (minuteStep) {
1041
+ return minute === '*/2' && !isContiguousHourRange(ir);
1042
+ }
1043
+
1044
+ // A minute list that is really a stride keeps its cadence form; a short
1045
+ // explicit minute list crossed with a discrete hour LIST is a wall of
1046
+ // distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
1047
+ // confinement. Both stay with the enumerating renderer.
1048
+ if (isMinuteStride(ir) ||
1049
+ ir.shapes.minute === 'list' && ir.shapes.hour === 'list') {
1050
+ return false;
1051
+ }
1052
+
1053
+ return true;
1054
+ }
1055
+
1056
+ // A minute-LEAD cadence (second :00). The existing renderers already produce
1057
+ // the blessed phrasing for a single/range/list hour and for a non-`*/2` hour
1058
+ // step; the confinement frame only changes the `*/2` hour ("of every other
1059
+ // hour") and the single hour under an "every other minute" step ("from
1060
+ // midnight until 1 a.m."). Everything else defers.
1061
+ if (hour === '*/2') {
1062
+ return true;
1063
+ }
1064
+
1065
+ return ir.shapes.hour === 'single' && minute === '*/2';
1066
+ }
1067
+
1068
+ // Whether the pattern reads with the confinement frame: a finer leading
1069
+ // cadence with each coarser field as a confinement. Routed to from the cadence
1070
+ // renderers in place of the older juxtaposed-cadence and duration-frame forms.
1071
+ function confinement(ir: IR, opts: NormalizedOptions): string | null {
1072
+ // The confinement frame is scoped to the default (US) dialect, the one that
1073
+ // carries the until-window; every other dialect and the compact `short` form
1074
+ // keep their established juxtaposed-cadence / duration-frame phrasing.
1075
+ if (!opts.style.untilWindow || opts.short) {
1076
+ return null;
1077
+ }
1078
+
1079
+ // With nothing coarser to confine (minute and hour both wildcard), the bare
1080
+ // cadence renderers already speak the pattern ("every second", "every
1081
+ // minute"); the confinement frame only applies once a coarser field is set.
1082
+ if (ir.pattern.minute === '*' && ir.pattern.hour === '*') {
1083
+ return null;
1084
+ }
1085
+
1086
+ const lead = leadingCadence(ir, opts);
1087
+
1088
+ if (!lead || !confinementEligible(ir, lead)) {
1089
+ return null;
1090
+ }
1091
+
1092
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : '';
1093
+
1094
+ return lead.text + minutePart + hourConfinement(ir, opts) +
1095
+ trailingQualifier(ir, opts);
742
1096
  }
743
1097
 
744
1098
  // The plan dispatch table.
@@ -790,10 +1144,9 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
790
1144
  pluralize(start, unit) + ' past the ' + anchor;
791
1145
  }
792
1146
 
793
- // A bounded, non-wrapping set: pin both endpoints. The two bounds share one
794
- // number style (all spelled, or all numerals once either crosses ten),
795
- // matching the range idiom ("from 0 through 30").
796
- const num = seriesNumber([start, last], opts);
1147
+ // A bounded, non-wrapping set: pin both endpoints. Each bound is a value, so
1148
+ // it reads as a digit, matching the range idiom ("from 0 through 30").
1149
+ const num = seriesNumber();
797
1150
 
798
1151
  return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
799
1152
  pluralize(last, unit) + ' past the ' + anchor;
@@ -1083,12 +1436,12 @@ function hourCadence(ir: IR, minute: number,
1083
1436
  // minute during every other hour", matching the "every minute during every
1084
1437
  // other hour" idiom and keeping it distinct from the bare hour-step form
1085
1438
  // ("every two hours") so the minute-0 confinement is never heard as it.
1086
- const confinement = minute === 0 && subMinuteSecond(ir) &&
1439
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
1087
1440
  cleanStrideSegment(ir);
1088
1441
 
1089
- if (confinement) {
1442
+ if (minuteZeroStride) {
1090
1443
  return secondsClause(ir, 'minute', opts) + ' for one minute ' +
1091
- everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
1444
+ everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
1092
1445
  }
1093
1446
 
1094
1447
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
@@ -1135,38 +1488,29 @@ function hasHourWindow(ir: IR): boolean {
1135
1488
  }
1136
1489
 
1137
1490
  // The hour-range window as a cadence tail at the top of each hour: each range
1138
- // segment is a "from X through Y" window ("every hour from 9 a.m. through
1139
- // 5 p.m."), and any non-contiguous single hour is appended ("and at 10 p.m.").
1140
- // The minute has already folded into the lead, so the window closes on the
1141
- // top of its final hour. Mirrors foldedHourWindows but pinned to minute 0.
1491
+ // segment is a window ("every hour from 9 a.m. until 9 p.m."), and any
1492
+ // non-contiguous single hour is appended by `outlierTail` ("plus 10 p.m." in
1493
+ // the default until-window form, "and at 10 p.m." elsewhere). The minute has
1494
+ // already folded into the lead, so the window closes on the top of its final
1495
+ // hour. Mirrors foldedHourWindows but pinned to minute 0.
1142
1496
  function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1143
1497
  const windows: string[] = [];
1144
- const singles: number[] = [];
1498
+ const outliers = collectHourOutliers(ir);
1145
1499
 
1146
1500
  // Reached only after hasHourWindow, so hour segments exist.
1147
1501
  ir.analyses.segments.hour!.forEach(function classify(segment) {
1148
1502
  if (segment.kind === 'range') {
1149
- windows.push('from ' + getTime({hour: +segment.bounds[0], minute: 0},
1150
- opts) + through(opts) +
1151
- getTime({hour: +segment.bounds[1], minute: 0}, opts));
1152
- }
1153
- else if (segment.kind === 'step') {
1154
- singles.push(...segment.fires);
1155
- }
1156
- else {
1157
- singles.push(+segment.value);
1503
+ windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1], 0,
1504
+ opts));
1158
1505
  }
1159
1506
  });
1160
1507
 
1161
- let phrase = 'every hour ' + joinList(windows, opts);
1162
-
1163
- if (singles.length) {
1164
- phrase += ' and at ' + joinList(singles.map(function time(hour) {
1165
- return getTime({hour, minute: 0}, opts);
1166
- }), opts);
1167
- }
1508
+ const phrase = 'every hour ' + joinList(windows, opts);
1509
+ const times = outliers.hours.map(function time(hour) {
1510
+ return getTime({hour, minute: 0}, opts);
1511
+ });
1168
1512
 
1169
- return phrase;
1513
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
1170
1514
  }
1171
1515
 
1172
1516
  // Render an hour range (or a list whose segments include a range) under a
@@ -1211,41 +1555,55 @@ function hourRangeCadence(ir: IR, minute: number,
1211
1555
 
1212
1556
  // --- List and segment phrasing. ---
1213
1557
 
1214
- // Chicago number style for a series: if any value crosses the spell-out
1215
- // boundary (greater than ten), render the whole series as numerals;
1216
- // otherwise spell each per getNumber. Keeps "five through ten" spelled
1217
- // but makes "0 through 29" all-numeral instead of "zero through 29".
1218
- function seriesNumber(values: (number | string)[], opts: NormalizedOptions):
1219
- (n: number | string) => string | number {
1220
- const anyBig = values.some(function big(v) {
1221
- return +v > 10;
1222
- });
1223
-
1558
+ // Number style for the bounds of a "from X through Y" series a range or a
1559
+ // pinned-endpoint stride. The boundary of a range is a clock/calendar VALUE,
1560
+ // not a frequency, so it always reads as a digit ("from 0 through 10", "from 1
1561
+ // through 5"), matching the minutes-/seconds-past convention; only the "every
1562
+ // N" multiplier keeps the spell-when-small style.
1563
+ function seriesNumber(): (n: number | string) => string | number {
1224
1564
  return function format(n) {
1225
- return anyBig ? '' + n : getNumber(n, opts);
1565
+ return '' + n;
1226
1566
  };
1227
1567
  }
1228
1568
 
1229
- // Render numeric fire values as number words, consistent across the set.
1569
+ // The number style for an enumerated set of values: a genuine LIST (two or
1570
+ // more comma-separated values) reads as numerals throughout ("at 4, 6, and 9
1571
+ // minutes past the hour") even when every value is small; a lone value keeps
1572
+ // the dialect's spelled-when-small style ("at five minutes past the hour"),
1573
+ // matching the single-value renderers. The list comma is the cue that pushes
1574
+ // the eye to numerals.
1575
+ function listNumber(count: number, opts: NormalizedOptions):
1576
+ (n: number | string) => string | number {
1577
+ return count > 1 ?
1578
+ function asNumeral(n) {
1579
+ return '' + n;
1580
+ } :
1581
+ function spelled(n) {
1582
+ return getNumber(n, opts);
1583
+ };
1584
+ }
1585
+
1586
+ // Render numeric fire values for an enumerated list: a multi-value list reads
1587
+ // as numerals, a lone value stays spelled (see `listNumber`).
1230
1588
  function numberWords(fires: number[],
1231
1589
  opts: NormalizedOptions): (string | number)[] {
1232
- return fires.map(seriesNumber(fires, opts));
1590
+ return fires.map(listNumber(fires.length, opts));
1233
1591
  }
1234
1592
 
1235
- // Render classified segments as words: singles as numbers, ranges as
1236
- // "<a> through <b>" pairs, step segments as their enumerated fires. The
1237
- // whole field shares one number style (all spelled or all numerals).
1593
+ // Render classified segments as words for an enumerated list: singles as
1594
+ // numbers, ranges as "<a> through <b>" pairs, step segments as their
1595
+ // enumerated fires. A multi-value list numeralizes throughout; a lone value
1596
+ // keeps the spelled-when-small style (see `listNumber`).
1238
1597
  function segmentWords(segments: Segment[],
1239
1598
  opts: NormalizedOptions): (string | number)[] {
1240
- const values = segments.flatMap(function collect(segment):
1241
- (string | number)[] {
1599
+ const count = segments.reduce(function tally(sum, segment) {
1242
1600
  if (segment.kind === 'range') {
1243
- return segment.bounds;
1601
+ return sum + 1;
1244
1602
  }
1245
1603
 
1246
- return segment.kind === 'step' ? segment.fires : [segment.value];
1247
- });
1248
- const num = seriesNumber(values, opts);
1604
+ return sum + (segment.kind === 'step' ? segment.fires.length : 1);
1605
+ }, 0);
1606
+ const num = listNumber(count, opts);
1249
1607
 
1250
1608
  return segments.flatMap(function word(segment) {
1251
1609
  if (segment.kind === 'range') {
@@ -1298,6 +1656,13 @@ function hourTimes(hours: number[], opts: NormalizedOptions): string {
1298
1656
  return joinList(times, opts);
1299
1657
  }
1300
1658
 
1659
+ // Whether an hour-times plan names exactly one hour. A lone hour is not a
1660
+ // list, so the cadence renderers keep the "at <time>" frame rather than the
1661
+ // plural "during the … hours" confinement.
1662
+ function singleHourFire(times: HourTimesPlan): boolean {
1663
+ return times.kind === 'fires' && times.fires.length === 1;
1664
+ }
1665
+
1301
1666
  // The hour times accompanying a window phrase: enumerated fires up to the
1302
1667
  // cap, segment rendering past it (decided by the core). `atContext` marks
1303
1668
  // an "at <times>" frame (vs "during the <times> hours").
@@ -1376,22 +1741,35 @@ function disambiguateTimes(pieces: string[], segments: Segment[],
1376
1741
  });
1377
1742
  }
1378
1743
 
1379
- // Join a list with commas and a terminal "and". The US dialect (Chicago)
1380
- // adds a serial comma before the "and" in lists of three or more; the UK
1744
+ // Join a list with commas and a terminal conjunction. The US dialect (Chicago)
1745
+ // adds a serial comma before the conjunction in lists of three or more; the UK
1381
1746
  // dialect (Guardian) does not. Pairs never take one.
1382
- function joinList(items: (string | number)[],
1747
+ function joinWith(items: (string | number)[], conjunction: string,
1383
1748
  opts: NormalizedOptions): string {
1384
1749
  if (items.length <= 1) {
1385
1750
  return items.join('');
1386
1751
  }
1387
1752
 
1388
1753
  if (items.length === 2) {
1389
- return items[0] + ' and ' + items[1];
1754
+ return items[0] + conjunction + items[1];
1390
1755
  }
1391
1756
 
1392
- const and = opts.style.serialComma ? ', and ' : ' and ';
1757
+ const tail = opts.style.serialComma ? ',' + conjunction : conjunction;
1758
+
1759
+ return items.slice(0, -1).join(', ') + tail + items[items.length - 1];
1760
+ }
1761
+
1762
+ // Join a list with a terminal "and" (the default English connective).
1763
+ function joinList(items: (string | number)[],
1764
+ opts: NormalizedOptions): string {
1765
+ return joinWith(items, ' and ', opts);
1766
+ }
1393
1767
 
1394
- return items.slice(0, -1).join(', ') + and + items[items.length - 1];
1768
+ // Join a list with a terminal "or", for an alternation such as a day-union
1769
+ // predicate list ("the 1st, a Sunday, or a weekday").
1770
+ function joinOr(items: (string | number)[],
1771
+ opts: NormalizedOptions): string {
1772
+ return joinWith(items, ' or ', opts);
1395
1773
  }
1396
1774
 
1397
1775
  // --- Day-level qualifiers. ---
@@ -1405,13 +1783,19 @@ interface QualifierWords {
1405
1783
  month: string;
1406
1784
  stepDate: string;
1407
1785
  weekday: string;
1786
+ // A trailing weekday is a recurring schedule and reads plural ("on
1787
+ // Mondays"); a leading time-anchored one names the day singular ("every
1788
+ // Monday at 9 a.m.").
1789
+ recurringWeekday: boolean;
1408
1790
  }
1409
1791
 
1410
- const trailingWords: QualifierWords =
1411
- {all: '', month: 'in ', stepDate: 'on ', weekday: 'on '};
1792
+ const trailingWords: QualifierWords = {
1793
+ all: '', month: 'in ', recurringWeekday: true, stepDate: 'on ', weekday: 'on '
1794
+ };
1412
1795
  const leadingWords: QualifierWords = {
1413
1796
  all: 'every day',
1414
1797
  month: 'every day in ',
1798
+ recurringWeekday: false,
1415
1799
  stepDate: '',
1416
1800
  weekday: 'every '
1417
1801
  };
@@ -1419,6 +1803,13 @@ const leadingWords: QualifierWords = {
1419
1803
  // A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
1420
1804
  // Returns an empty string when no date, month, or weekday is set.
1421
1805
  function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1806
+ // A day union reframes both day fields as a trailing condition clause; the
1807
+ // month leads the whole description (applied in `describe`), so it is not
1808
+ // part of the trailing qualifier here.
1809
+ if (isDayUnion(ir, opts)) {
1810
+ return dayUnionCondition(ir, opts);
1811
+ }
1812
+
1422
1813
  const phrase = dayQualifier(ir, trailingWords, opts);
1423
1814
 
1424
1815
  return phrase && ' ' + phrase;
@@ -1427,6 +1818,13 @@ function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1427
1818
  // Build the day-level qualifier that precedes a specific time, e.g.
1428
1819
  // "every day ", "every Friday ", or "on January 13 ".
1429
1820
  function interpretDayQualifier(ir: IR, opts: NormalizedOptions): string {
1821
+ // A day union puts the time first ("at midnight whenever the day is …"), so
1822
+ // the leading position contributes no day phrase; the condition clause is
1823
+ // appended after the time by the clock renderer.
1824
+ if (isDayUnion(ir, opts)) {
1825
+ return '';
1826
+ }
1827
+
1430
1828
  return dayQualifier(ir, leadingWords, opts) + ' ';
1431
1829
  }
1432
1830
 
@@ -1450,8 +1848,17 @@ function dayQualifier(ir: IR, words: QualifierWords,
1450
1848
  // A weekday qualifier, optionally scoped to a month ("on Monday in
1451
1849
  // June").
1452
1850
  if (pattern.weekday !== '*') {
1453
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) ||
1454
- words.weekday + weekdayPhrase(ir, opts);
1851
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1852
+
1853
+ // The Quartz weekday phrase ("on the last Friday of the month") carries
1854
+ // the "of the month" recurrence a concrete month makes redundant; a plain
1855
+ // weekday name takes the ordinary " in <month>" scope.
1856
+ if (quartzWeekday) {
1857
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1858
+ }
1859
+
1860
+ const weekdays = words.weekday +
1861
+ weekdayPhrase(ir, words.recurringWeekday, opts);
1455
1862
 
1456
1863
  return weekdays + monthScope(ir, opts);
1457
1864
  }
@@ -1470,11 +1877,12 @@ function datePhrase(ir: IR, words: QualifierWords,
1470
1877
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1471
1878
 
1472
1879
  if (quartzDate) {
1473
- return quartzDate + monthScope(ir, opts);
1880
+ return monthScopeForRecurrence(quartzDate, ir, opts);
1474
1881
  }
1475
1882
 
1476
1883
  if (isOpenStep(pattern.date)) {
1477
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1884
+ return monthScopeForRecurrence(
1885
+ words.stepDate + stepDates(pattern.date), ir, opts);
1478
1886
  }
1479
1887
 
1480
1888
  if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
@@ -1502,6 +1910,148 @@ function monthFoldsIntoDate(ir: IR): boolean {
1502
1910
  });
1503
1911
  }
1504
1912
 
1913
+ // When BOTH the date and weekday are restricted, cron fires on the UNION of
1914
+ // the two day sets — a point the old "on <dom> or on <dow>" form blurred,
1915
+ // reading as alternatives (or, with "and", as an intersection). The default
1916
+ // dialect reframes the union as a predicate over a single variable, the day:
1917
+ // "whenever the day is <dom-predicate> or <dow-predicate(s)>", a flat or-list
1918
+ // that reads as a union for naive, logical, and technical readers alike. The
1919
+ // month leads the whole clause ("in June …") and the time/cadence sits between
1920
+ // the two, so this form is composed at the top level (see `dayUnionMonthLead`
1921
+ // and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
1922
+ // to the until-window dialect; every other dialect and the `short` form keep
1923
+ // the established "on <dom> or on <dow>" phrasing.
1924
+ function isDayUnion(ir: IR, opts: NormalizedOptions): boolean {
1925
+ return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
1926
+ !!opts.style.untilWindow && !opts.short;
1927
+ }
1928
+
1929
+ // The trailing condition clause for a day union, e.g. " whenever the day is
1930
+ // the 1st or a Friday". The day predicates are flattened into one or-list so
1931
+ // the union reads as a single set of matching days.
1932
+ function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
1933
+ const pieces = [...dayUnionDatePieces(ir, opts),
1934
+ ...dayUnionWeekdayPieces(ir, opts)];
1935
+
1936
+ return ' whenever the day is ' + joinOr(pieces, opts);
1937
+ }
1938
+
1939
+ // The leading "in <month> " scope for a day union, or an empty string when the
1940
+ // month is a wildcard. The month scopes the whole union, so it leads the clause
1941
+ // rather than attaching to either day half.
1942
+ function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
1943
+ if (ir.pattern.month === '*') {
1944
+ return '';
1945
+ }
1946
+
1947
+ return 'in ' + monthName(ir, opts) + ' ';
1948
+ }
1949
+
1950
+ // The day-of-month half of a union as a flat list of predicate pieces. A
1951
+ // Quartz date is its definite phrase ("the last day of the month"); an open
1952
+ // `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
1953
+ // reads each segment as "the <ordinal>" or "from the <ordinal> through the
1954
+ // <ordinal>".
1955
+ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1956
+ const dateField = ir.pattern.date;
1957
+ const quartz = quartzDatePhrase(dateField, opts);
1958
+
1959
+ if (quartz) {
1960
+ return [quartz.replace(/^on /, '')];
1961
+ }
1962
+
1963
+ const oddEven = oddEvenDay(dateField);
1964
+
1965
+ if (oddEven) {
1966
+ return [oddEven];
1967
+ }
1968
+
1969
+ // Reached only with a restricted, non-Quartz date, which has segments. Each
1970
+ // segment contributes its predicate piece(s) to the flat union list; a step
1971
+ // spreads its enumerated fires as separate "the <ordinal>" alternatives.
1972
+ const pieces: string[] = [];
1973
+
1974
+ ir.analyses.segments.date!.forEach(function expand(segment) {
1975
+ if (segment.kind === 'range') {
1976
+ pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
1977
+ 'the ' + getOrdinal(segment.bounds[1]));
1978
+ }
1979
+ else if (segment.kind === 'step') {
1980
+ segment.fires.forEach(function fire(value) {
1981
+ pieces.push('the ' + getOrdinal(value));
1982
+ });
1983
+ }
1984
+ else {
1985
+ pieces.push('the ' + getOrdinal(segment.value));
1986
+ }
1987
+ });
1988
+
1989
+ return pieces;
1990
+ }
1991
+
1992
+ // The day-of-week half of a union as a flat list of predicate pieces. A Quartz
1993
+ // weekday is its definite phrase ("the last Friday of the month"); the Monday-
1994
+ // through-Friday range is the "a weekday" idiom; every other weekday names each
1995
+ // day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
1996
+ // kind of day the union can match.
1997
+ function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
1998
+ const weekdayField = ir.pattern.weekday;
1999
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
2000
+
2001
+ if (quartz) {
2002
+ return [quartz.replace(/^on /, '')];
2003
+ }
2004
+
2005
+ // The union predicate keeps the canonical Sunday-first order (0…6) rather
2006
+ // than the weekend-last display order: as a flat or-list of day kinds, the
2007
+ // numeric order reads as naturally as any other and matches the reviewed
2008
+ // spec ("a Sunday, a Tuesday, a Thursday, or a Saturday").
2009
+ const pieces: string[] = [];
2010
+
2011
+ ir.analyses.segments.weekday!.forEach(function expand(segment) {
2012
+ if (segment.kind === 'range' &&
2013
+ segment.bounds[0] === '1' && segment.bounds[1] === '5') {
2014
+ pieces.push('a weekday');
2015
+ }
2016
+ else if (segment.kind === 'range') {
2017
+ pieces.push('a ' + getWeekday(segment.bounds[0], opts) + through(opts) +
2018
+ 'a ' + getWeekday(segment.bounds[1], opts));
2019
+ }
2020
+ else if (segment.kind === 'step') {
2021
+ segment.fires.forEach(function fire(value) {
2022
+ pieces.push('a ' + getWeekday(value, opts));
2023
+ });
2024
+ }
2025
+ else {
2026
+ pieces.push('a ' + getWeekday(segment.value, opts));
2027
+ }
2028
+ });
2029
+
2030
+ return pieces;
2031
+ }
2032
+
2033
+ // An interval-2 day-of-month step covering a parity set reads as "an
2034
+ // odd/even-numbered day", mirroring the month and year parity idioms: `*/2`
2035
+ // and `1/2` are the odd days, `2/2` the even; any other start enumerates
2036
+ // instead. Null when the field is not an open interval-2 step.
2037
+ function oddEvenDay(dateField: string): string | null {
2038
+ if (!isOpenStep(dateField)) {
2039
+ return null;
2040
+ }
2041
+
2042
+ const [start, step] = dateField.split('/');
2043
+
2044
+ if (+step !== 2) {
2045
+ return null;
2046
+ }
2047
+
2048
+ if (start === '*' || start === '1') {
2049
+ return 'an odd-numbered day';
2050
+ }
2051
+
2052
+ return start === '2' ? 'an even-numbered day' : null;
2053
+ }
2054
+
1505
2055
  // Compose the "day-of-month or day-of-week" phrase used when both fields
1506
2056
  // are restricted: cron fires when either is a match. A restricted month
1507
2057
  // scopes BOTH halves, so it attaches to the whole or, never to a single
@@ -1511,8 +2061,10 @@ function monthFoldsIntoDate(ir: IR): boolean {
1511
2061
  // odd/even frequency) it trails the whole or as ", in <month>".
1512
2062
  function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
1513
2063
  const pattern = ir.pattern;
2064
+ // The day-of-month-OR-day-of-week union is out of scope for the recurring
2065
+ // plural (it is reframed elsewhere): the weekday half stays singular here.
1514
2066
  const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
1515
- 'on ' + weekdayPhrase(ir, opts);
2067
+ 'on ' + weekdayPhrase(ir, false, opts);
1516
2068
 
1517
2069
  if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
1518
2070
  !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
@@ -1599,6 +2151,12 @@ function quartzWeekdayPhrase(weekdayField: string,
1599
2151
  // A calendar date with its month, in the dialect's order and day form:
1600
2152
  // cardinal "January 1" / "1 January", or ordinal "January 1st" for
1601
2153
  // dialects that set `ordinals`.
2154
+ //
2155
+ // A day-first dialect places the day before the month, but a single day before
2156
+ // a MULTI-month list garden-paths — "13 January, April, July and October"
2157
+ // reads as if the 13 belongs to January alone. The day is reattached to the
2158
+ // whole list with the possessive "the <ordinal> of <months>", which names the
2159
+ // same day across every month unambiguously.
1602
2160
  function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
1603
2161
  const month = monthName(ir, opts);
1604
2162
  // A month-day phrase is reached only with a restricted date, which has
@@ -1606,6 +2164,11 @@ function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
1606
2164
  const days = renderSegments(ir.analyses.segments.date!,
1607
2165
  opts.style.ordinals ? getOrdinal : cardinalDay, opts);
1608
2166
 
2167
+ if (opts.style.dayFirst && ir.shapes.date === 'single' &&
2168
+ ir.shapes.month !== 'single') {
2169
+ return 'the ' + getOrdinal(ir.pattern.date) + ' of ' + month;
2170
+ }
2171
+
1609
2172
  return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
1610
2173
  }
1611
2174
 
@@ -1624,6 +2187,35 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
1624
2187
  return ' in ' + monthName(ir, opts);
1625
2188
  }
1626
2189
 
2190
+ // Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
2191
+ // day / last-weekday / nth-weekday forms and the open day-of-month step) by a
2192
+ // named month. A concrete month — a single name or a step ("every odd-numbered
2193
+ // month", "January, April, …") — makes "of the month" redundant: it names that
2194
+ // one month, so the phrase drops it and reads "in <month>". A month RANGE
2195
+ // distributes the recurrence across the span and keeps it, rephrased as "of
2196
+ // each month from <first> through <last>". A month list is left as-is (the
2197
+ // recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
2198
+ function monthScopeForRecurrence(phrase: string, ir: IR,
2199
+ opts: NormalizedOptions): string {
2200
+ if (ir.pattern.month === '*') {
2201
+ return phrase;
2202
+ }
2203
+
2204
+ const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
2205
+
2206
+ if (carriesRecurrence && ir.shapes.month === 'range') {
2207
+ return phrase.replace(' of the month', ' of each month') + ' from ' +
2208
+ monthName(ir, opts);
2209
+ }
2210
+
2211
+ if (carriesRecurrence &&
2212
+ (ir.shapes.month === 'single' || ir.shapes.month === 'step')) {
2213
+ return phrase.replace(' of the month', '') + ' in ' + monthName(ir, opts);
2214
+ }
2215
+
2216
+ return phrase + ' in ' + monthName(ir, opts);
2217
+ }
2218
+
1627
2219
  // Frequency phrase for an open day-of-month step, e.g. "every other day of
1628
2220
  // the month" or "every 3rd day of the month from the 5th".
1629
2221
  function stepDates(dateField: string): string {
@@ -1690,16 +2282,44 @@ function oddEvenMonth(monthField: string): string | null {
1690
2282
  }
1691
2283
 
1692
2284
  // Render the weekday field as names. Ranges read in their connective form
1693
- // ("Monday through Friday", or "Mon-Fri" with `short`).
1694
- function weekdayPhrase(ir: IR, opts: NormalizedOptions): string {
2285
+ // ("Monday through Friday", or "Mon-Fri" with `short`). When `recurring`, a
2286
+ // trailing single or list weekday is a repeating schedule and reads plural
2287
+ // ("on Mondays", "on Mondays and Wednesdays"), matching es/de/fi; a RANGE
2288
+ // keeps the singular idiom ("on Monday through Friday") so its through-
2289
+ // connective stays unmistakable, and a leading time-anchored form ("every
2290
+ // Monday") is never recurring here.
2291
+ function weekdayPhrase(ir: IR, recurring: boolean,
2292
+ opts: NormalizedOptions): string {
1695
2293
  // Reached only with a restricted weekday, which has segments. Weekday lists
1696
2294
  // display Monday-first (Sunday last) so a weekend reads naturally; the IR
1697
2295
  // stays canonical (Sunday=0) and ranges keep their form.
1698
2296
  const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
2297
+ const hasRange = segments.some(function range(segment) {
2298
+ return segment.kind === 'range';
2299
+ });
1699
2300
 
1700
- return renderSegments(segments, function name(value) {
1701
- return getWeekday(value, opts);
1702
- }, opts);
2301
+ // A range pins the singular idiom for the whole phrase ("Monday through
2302
+ // Friday"); only an all-single/step set pluralizes its names.
2303
+ const name = recurring && !hasRange ?
2304
+ function plural(value: number | string): string {
2305
+ return pluralWeekday(value, opts);
2306
+ } :
2307
+ function singular(value: number | string): string {
2308
+ return getWeekday(value, opts);
2309
+ };
2310
+
2311
+ return renderSegments(segments, name, opts);
2312
+ }
2313
+
2314
+ // The recurring (plural) form of a weekday name: every English weekday name
2315
+ // pluralizes by appending "s" ("Mondays", "Sundays"). The `short`
2316
+ // abbreviation keeps its singular form — "on Mons" reads as an error, not a
2317
+ // plural.
2318
+ function pluralWeekday(value: number | string,
2319
+ opts: NormalizedOptions): string {
2320
+ const name = getWeekday(value, opts);
2321
+
2322
+ return opts.short ? name : name + 's';
1703
2323
  }
1704
2324
 
1705
2325
  // Render classified field segments with `word`, expanding step segments
@@ -1746,7 +2366,10 @@ function applyYear(description: string, ir: IR,
1746
2366
  }
1747
2367
 
1748
2368
  if (yearField.indexOf('/') !== -1) {
1749
- return description + ' ' + stepYears(yearField, opts);
2369
+ // A year step is a coarser cadence juxtaposed on the finished clause: a
2370
+ // clause comma separates it ("every second, every other year"), matching
2371
+ // how every other juxtaposed clause is joined.
2372
+ return description + ', ' + stepYears(yearField, opts);
1750
2373
  }
1751
2374
 
1752
2375
  const label = yearLabel(yearField, opts);
@@ -1769,6 +2392,12 @@ function yearLabel(yearField: string, opts: NormalizedOptions): string {
1769
2392
  return joinList(yearField.split(','), opts);
1770
2393
  }
1771
2394
 
2395
+ // A year range reads with the dialect's range connective ("2030 through
2396
+ // 2035"), the same form every other field uses, not a raw hyphen.
2397
+ if (yearField.indexOf('-') !== -1) {
2398
+ return yearField.split('-').join(through(opts));
2399
+ }
2400
+
1772
2401
  return yearField;
1773
2402
  }
1774
2403
 
@@ -1783,7 +2412,11 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1783
2412
  return 'every year';
1784
2413
  }
1785
2414
 
1786
- let phrase = 'every ' + getNumber(interval, opts) + ' years';
2415
+ // Interval 2 reads as the parity idiom ("every other year"), matching the
2416
+ // month and day-of-month step forms; longer intervals count the years.
2417
+ let phrase = interval === 2 ?
2418
+ 'every other year' :
2419
+ 'every ' + getNumber(interval, opts) + ' years';
1787
2420
 
1788
2421
  if (start !== '*' && start !== '0') {
1789
2422
  phrase += ' from ' + start;