cronli5 0.1.7 → 0.2.1

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.
@@ -3,9 +3,13 @@
3
3
  // the core stays semantic, and this module's only input is the IR.
4
4
  // See docs/i18n-design.md.
5
5
 
6
- import {arithmeticStep, orderWeekdaysForDisplay} from '../../core/util.js';
6
+ import {
7
+ arithmeticStep, hourListStride, offsetCleanStride, orderWeekdaysForDisplay,
8
+ segmentsOf, singleValues, stepSegment
9
+ } from '../../core/util.js';
10
+ import {isOpenStep} from '../../core/shapes.js';
7
11
  import {maxClockTimes} from '../../core/specs.js';
8
- import {clockDigits, numeral} from '../../core/format.js';
12
+ import {clockDigits, numeral, pad} from '../../core/format.js';
9
13
  import type {Cronli5Options} from '../../types.js';
10
14
  import type {
11
15
  HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -33,6 +37,17 @@ interface Stride {
33
37
  anchor: string;
34
38
  }
35
39
 
40
+ // A contiguous hour range to phrase as a window. `from`/`to` are the bounding
41
+ // hours; `throughMinute` is the close minute used by the "through" span;
42
+ // `continuous` is true only when the run fills every minute of the final hour
43
+ // (a wildcard minute), which earns the default dialect's until-window.
44
+ interface HourWindowSpec {
45
+ from: number;
46
+ to: number;
47
+ throughMinute: number | string;
48
+ continuous: boolean;
49
+ }
50
+
36
51
  // A clock-time entry assembled for rendering. Hour/minute/second arrive as
37
52
  // numbers or as raw field tokens (a range bound or single value is a
38
53
  // string); `plain` suppresses the noon/midnight words. `explicit` forces the
@@ -130,7 +145,16 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
130
145
 
131
146
  // Render an analyzed cron pattern (the IR) as English.
132
147
  function describe(ir: IR, opts: NormalizedOptions): string {
133
- return applyYear(render(ir, ir.plan, opts), ir, opts);
148
+ // A finer leading cadence puts each coarser field in the confinement frame,
149
+ // overriding the per-plan juxtaposed-cadence and duration-frame forms.
150
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
151
+
152
+ // A day union scopes the whole clause by its month, which leads the
153
+ // description ("in June <time> whenever the day is …"); the time/cadence and
154
+ // the trailing condition are already in `body`.
155
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : '';
156
+
157
+ return applyYear(lead + body, ir, opts);
134
158
  }
135
159
 
136
160
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
@@ -206,9 +230,6 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
206
230
  return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
207
231
  }
208
232
 
209
- // A meaningful second under minute/hour shapes the earlier strategies
210
- // deferred on: the second leads with its own clause and the rest of the
211
- // pattern follows.
212
233
  // A wildcard or stepped second under a fixed minute across one or more specific
213
234
  // hours. The clock-time rest collapses the pinned minute into the hour, and on
214
235
  // the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
@@ -331,13 +352,13 @@ function secondsClause(ir: IR, anchor: string,
331
352
  if (shape === 'step') {
332
353
  // The plan reached this clause only for a stepped second field, whose
333
354
  // first segment is always a step segment.
334
- return stepCycle60(ir.analyses.segments.second![0] as StepSegment,
355
+ return stepCycle60(stepSegment(ir, 'second'),
335
356
  'second', anchor, opts);
336
357
  }
337
358
 
338
359
  if (shape === 'range') {
339
360
  const bounds = secondField.split('-');
340
- const num = seriesNumber(bounds, opts);
361
+ const num = seriesNumber();
341
362
 
342
363
  return 'every second from ' + num(bounds[0]) +
343
364
  through(opts) + num(bounds[1]) + ' past the ' + anchor;
@@ -351,8 +372,8 @@ function secondsClause(ir: IR, anchor: string,
351
372
  // A non-wildcard second under the list/step path always has segments. An
352
373
  // offset/uneven step the core enumerated to a fire list reads as a stride
353
374
  // cadence when those fires form a long-enough progression.
354
- return strideFromSegments(ir.analyses.segments.second!, 'second', anchor,
355
- opts) ?? listPastThe(segmentWords(ir.analyses.segments.second!, opts),
375
+ return strideFromSegments(segmentsOf(ir, 'second'), 'second', anchor,
376
+ opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'second'), opts),
356
377
  'second', anchor, opts);
357
378
  }
358
379
 
@@ -384,9 +405,9 @@ function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
384
405
  // segments. An offset/uneven step the core enumerated to this list reads as
385
406
  // a stride cadence when the fires form a long-enough progression.
386
407
  const stride =
387
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts);
408
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts);
388
409
 
389
- return (stride ?? listPastThe(segmentWords(ir.analyses.segments.minute!,
410
+ return (stride ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'),
390
411
  opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
391
412
  }
392
413
 
@@ -395,7 +416,7 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
395
416
  opts: NormalizedOptions): string {
396
417
  // A minute-frequency plan is selected only for a stepped minute field,
397
418
  // which has segments.
398
- let phrase = stepCycle60(ir.analyses.segments.minute![0] as StepSegment,
419
+ let phrase = stepCycle60(stepSegment(ir, 'minute'),
399
420
  'minute', 'hour', opts);
400
421
 
401
422
  if (plan.hours.kind === 'during') {
@@ -410,14 +431,23 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
410
431
  hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
411
432
  }
412
433
  else if (plan.hours.kind === 'window') {
413
- phrase += ' ' + hourWindow(plan.hours, opts);
434
+ // A minute-frequency cadence ("every 15 minutes") fills the hours from a
435
+ // STEPPED minute, never a wildcard one, so its run is not continuous to the
436
+ // top of the next hour: the default dialect reads "through <last hour>" and
437
+ // every other dialect closes on the step's last fire (`last`).
438
+ phrase += ' ' + rangeWindow({
439
+ continuous: false,
440
+ from: plan.hours.from,
441
+ throughMinute: plan.hours.last,
442
+ to: plan.hours.to
443
+ }, opts);
414
444
  }
415
445
  else if (plan.hours.kind === 'step') {
416
446
  // The plan carries a step only for a clean stride (dividing the day),
417
447
  // which confines the cadence to every Nth hour; a stepped hour field's
418
448
  // first segment is a step segment.
419
449
  phrase += ' ' +
420
- everyNthHour(ir.analyses.segments.hour![0] as StepSegment, opts);
450
+ everyNthHour(stepSegment(ir, 'hour'), opts);
421
451
  }
422
452
 
423
453
  return phrase + trailingQualifier(ir, opts);
@@ -460,12 +490,36 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
460
490
  trailingQualifier(ir, opts);
461
491
  }
462
492
 
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.
467
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
468
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
493
+ if (plan.form === 'range') {
494
+ const lead = minuteRangeLead(ir.pattern.minute, opts);
495
+
496
+ if (cadence !== null) {
497
+ return lead + ', ' + cadence + trailingQualifier(ir, opts);
498
+ }
499
+
500
+ // A plain minute range is a cadence, so an hour list confines it with the
501
+ // "during the … hours" idiom — the same reading the seconds-leading
502
+ // sibling and the wildcard-minute form already use — rather than a
503
+ // clock-time "at <times>" list, which reads as discrete fire points. A
504
+ // lone hour is not a list, so it keeps the "at <time>" frame ("…past the
505
+ // hour, at 9 a.m."), never the plural "hours" confinement.
506
+ if (singleHourFire(plan.times)) {
507
+ return lead + ', at ' +
508
+ hourTimesFromPlan(ir, plan.times, true, opts) +
509
+ trailingQualifier(ir, opts);
510
+ }
511
+
512
+ return lead + ' during the ' +
513
+ hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
514
+ trailingQualifier(ir, opts);
515
+ }
516
+
517
+ // The 'list' form is a minute list, which has segments; an offset/uneven
518
+ // step enumerated to that list reads as a stride. A list is a set of
519
+ // discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
520
+ const lead =
521
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
522
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
469
523
  'minute', 'hour', opts);
470
524
 
471
525
  if (cadence !== null) {
@@ -501,7 +555,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
501
555
  plan: PlanOf<'minuteSpanAcrossHourStep'>, opts: NormalizedOptions): string {
502
556
  // This plan is reached only under a stepped hour field, whose first
503
557
  // segment is a step segment.
504
- const segment = ir.analyses.segments.hour![0] as StepSegment;
558
+ const segment = stepSegment(ir, 'hour');
505
559
 
506
560
  // A wildcard minute over a stepped hour is reached only for a clean stride
507
561
  // (a bounded or uneven step routes through minutesAcrossHours instead), so it
@@ -514,8 +568,8 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
514
568
  // A minute list keeps the same cadence clause; only its lead differs. An
515
569
  // offset/uneven step the core enumerated to that list reads as a stride.
516
570
  const lead = plan.form === 'list' ?
517
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
518
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
571
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
572
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
519
573
  'minute', 'hour', opts) :
520
574
  minuteRangeLead(ir.pattern.minute, opts);
521
575
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
@@ -532,7 +586,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
532
586
  function minuteRangeLead(minuteField: string,
533
587
  opts: NormalizedOptions): string {
534
588
  const bounds = minuteField.split('-');
535
- const num = seriesNumber(bounds, opts);
589
+ const num = seriesNumber();
536
590
 
537
591
  return 'every minute from ' + num(bounds[0]) + through(opts) +
538
592
  num(bounds[1]) + ' past the hour';
@@ -574,8 +628,8 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
574
628
 
575
629
  // A non-"0" minute here is a discrete list, which has segments; an
576
630
  // offset/uneven step enumerated to that list reads as a stride.
577
- return strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour',
578
- opts) ?? listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
631
+ return strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour',
632
+ opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
579
633
  'minute', 'hour', opts);
580
634
  }
581
635
 
@@ -592,7 +646,7 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
592
646
 
593
647
  // An hour-step plan is selected only for a stepped hour field, whose
594
648
  // first segment is a step segment.
595
- return stepHours(ir.analyses.segments.hour![0] as StepSegment, opts) +
649
+ return stepHours(stepSegment(ir, 'hour'), opts) +
596
650
  trailingQualifier(ir, opts);
597
651
  }
598
652
 
@@ -601,21 +655,61 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
601
655
  // a wildcard minute, which fills every minute and states no separate clause.
602
656
  // A pinned/listed/ranged minute is named in its own lead clause, so folding it
603
657
  // into the close too would read as a span ("through 5:05 p.m.") that
604
- // contradicts the minute clause; the window stays bare ("through 5 p.m.").
658
+ // contradicts the minute clause; the window stays bare ("through 5 p.m."). The
659
+ // same wildcard minute is what makes the run CONTINUOUS to the top of the next
660
+ // hour, so it also drives the until-window choice in `rangeWindow`.
605
661
  function boundedWindow(plan: PlanOf<'hourRange'>):
606
- {from: number; to: number; last: number} {
607
- const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
662
+ {from: number; to: number; closeMinute: number; continuous: boolean} {
663
+ const continuous = plan.minuteForm === 'wildcard';
664
+ const closeMinute = continuous ? plan.boundMinute ?? 0 : 0;
665
+
666
+ return {from: plan.from, closeMinute, to: plan.to, continuous};
667
+ }
668
+
669
+ // A contiguous hour range as a window phrase. The default English dialect
670
+ // reads a MULTI-hour range whose run is CONTINUOUS to the top of the next hour
671
+ // as an up-to-but-not-including window — "from 9 a.m. until 6 p.m." (the close
672
+ // is the top of the hour after the last, the sense English uses for time
673
+ // windows: 9-17 runs until 6 p.m.); 23 wraps to midnight. The run is continuous
674
+ // only when the minute is wildcard, so every minute of the final hour fires; a
675
+ // restricted minute fires at discrete points (e.g. only `:00`), so the run
676
+ // stops within the final hour and the default dialect reverts to the bare
677
+ // "through <last hour>" span (the minute is named in its own lead clause, so
678
+ // the close stays on the top of the final hour rather than restating a last
679
+ // fire). Every other dialect (and the compact `short` form) always speaks the
680
+ // span, closing on the minute field's last fire within the final hour. A
681
+ // single-hour sub-hour window (`from === to`, e.g. */15 9 firing 9:00 through
682
+ // 9:45) is NOT a multi-hour range: its close is a real fire inside the hour, so
683
+ // it always keeps "through" — naming "until 10 a.m." would overstate the span
684
+ // past the last fire.
685
+ function rangeWindow(window: HourWindowSpec,
686
+ opts: NormalizedOptions): string {
687
+ const {from, to, throughMinute, continuous} = window;
688
+ const open = 'from ' + getTime({hour: from, minute: 0}, opts);
608
689
 
609
- return {from: plan.from, last, to: plan.to};
690
+ if (opts.style.untilWindow && !opts.short && from !== to) {
691
+ return continuous ?
692
+ open + ' until ' + getTime({hour: (to + 1) % 24, minute: 0}, opts) :
693
+ open + through(opts) + getTime({hour: to, minute: 0}, opts);
694
+ }
695
+
696
+ return open + through(opts) +
697
+ getTime({hour: to, minute: throughMinute}, opts);
610
698
  }
611
699
 
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.
615
- function hourWindow(window: {from: number; to: number; last: number},
700
+ // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m." (or "from 9 a.m.
701
+ // until 6 p.m." in the default dialect, when the minute is wildcard). Windows
702
+ // open at the top of the first hour and close at the minute field's last fire
703
+ // within the final hour.
704
+ function hourWindow(
705
+ window: {from: number; to: number; closeMinute: number; continuous: boolean},
616
706
  opts: NormalizedOptions): string {
617
- return 'from ' + getTime({hour: window.from, minute: 0}, opts) +
618
- through(opts) + getTime({hour: window.to, minute: window.last}, opts);
707
+ return rangeWindow({
708
+ continuous: window.continuous,
709
+ from: window.from,
710
+ throughMinute: window.closeMinute,
711
+ to: window.to
712
+ }, opts);
619
713
  }
620
714
 
621
715
  // Expand a discrete set of hours and minutes into clock times prefixed by
@@ -645,7 +739,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
645
739
  }, opts);
646
740
  });
647
741
 
648
- return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts);
742
+ return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts) +
743
+ dayUnionTrail(ir, opts);
744
+ }
745
+
746
+ // The trailing day-union condition for a clock-time form (which leads with its
747
+ // time, not a day qualifier), or an empty string when the pattern is not a day
748
+ // union. The cadence renderers carry this through `trailingQualifier` instead.
749
+ function dayUnionTrail(ir: IR, opts: NormalizedOptions): string {
750
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : '';
649
751
  }
650
752
 
651
753
  // Compact form for a clock-time set past the enumeration cap. A single
@@ -666,7 +768,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
666
768
 
667
769
  // A compact clock-time plan is reached only for discrete hours, which
668
770
  // have segments.
669
- const hasRange = ir.analyses.segments.hour!.some(function range(segment) {
771
+ const hasRange = segmentsOf(ir, 'hour').some(function range(segment) {
670
772
  return segment.kind === 'range';
671
773
  });
672
774
 
@@ -679,14 +781,14 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
679
781
  const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
680
782
 
681
783
  return interpretDayQualifier(ir, opts) + 'at ' +
682
- hourSegmentTimes(ir, fold, true, opts);
784
+ hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
683
785
  }
684
786
 
685
787
  const minuteLead =
686
788
  // The non-fold branch is a minute list, which has segments. An
687
789
  // offset/uneven step enumerated to that list reads as a stride.
688
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
689
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
790
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
791
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
690
792
  'minute', 'hour', opts);
691
793
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
692
794
  // of clock-time columns.
@@ -706,39 +808,330 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
706
808
 
707
809
  // A folded hour field that includes a contiguous range reads with the
708
810
  // 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".
811
+ // past the hour"), each range as a window, and any non-contiguous hour
812
+ // appended by `outlierTail` ("and at Z").
711
813
  function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
712
814
  opts: NormalizedOptions): string {
713
815
  const minute = plan.minute;
714
816
  const windows: string[] = [];
715
- const singles: number[] = [];
817
+ const times = collectHourOutliers(ir).map(function time(hour) {
818
+ return getTime({hour, minute}, opts);
819
+ });
716
820
 
717
- // Reached only via the fold branch under discrete hours, which have
718
- // segments.
719
- ir.analyses.segments.hour!.forEach(function classify(segment) {
821
+ // Reached only via the fold branch under discrete hours, which have segments.
822
+ // A folded minute is a discrete pin/list, never a wildcard, so the run is not
823
+ // continuous to the top of the next hour: the window is not an until-window.
824
+ segmentsOf(ir, 'hour').forEach(function classify(segment) {
720
825
  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));
826
+ windows.push(rangeWindow({
827
+ continuous: false,
828
+ from: +segment.bounds[0],
829
+ throughMinute: minute,
830
+ to: +segment.bounds[1]
831
+ }, opts));
724
832
  }
725
- else if (segment.kind === 'step') {
726
- singles.push(...segment.fires);
833
+ });
834
+
835
+ const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
836
+
837
+ return phrase + outlierTail(times, opts);
838
+ }
839
+
840
+ // The hours outside a contiguous run — every non-range segment's values, with
841
+ // a step contributing its whole fire set.
842
+ function collectHourOutliers(ir: IR): number[] {
843
+ const hours: number[] = [];
844
+
845
+ // Reached only under discrete hours, which carry segments.
846
+ segmentsOf(ir, 'hour').forEach(function classify(segment) {
847
+ if (segment.kind === 'step') {
848
+ hours.push(...segment.fires);
727
849
  }
728
- else {
729
- singles.push(+segment.value);
850
+ else if (segment.kind !== 'range') {
851
+ hours.push(+segment.value);
730
852
  }
731
853
  });
732
854
 
733
- let phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
855
+ return hours;
856
+ }
734
857
 
735
- if (singles.length) {
736
- phrase += ' and at ' + joinList(singles.map(function time(hour) {
737
- return getTime({hour, minute}, opts);
738
- }), opts);
858
+ // Join the outlier hour times that follow a contiguous-run window — the hours
859
+ // outside the run, enumerated as "and at 10 p.m.". (A fold always carries a
860
+ // restricted minute, so its run reads the "through" span, never the
861
+ // until-window; the additive "plus" idiom that paired with the until-window no
862
+ // longer applies here.)
863
+ function outlierTail(times: string[], opts: NormalizedOptions): string {
864
+ if (!times.length) {
865
+ return '';
739
866
  }
740
867
 
741
- return phrase;
868
+ return ' and at ' + joinList(times, opts);
869
+ }
870
+
871
+ // --- Confinement frame. ---
872
+ //
873
+ // Under a finer LEADING CADENCE — the finest restricted field spoken as a
874
+ // recurrence ("every second", "every 15 seconds", "every minute", "every two
875
+ // minutes") — each COARSER restricted field reads as a confinement, not a
876
+ // juxtaposed cadence: "every second during minute :00 of every hour", "every
877
+ // second of the midnight hour", "every two minutes from midnight until 1 a.m.".
878
+ // A redundant unrestricted finer field drops ("every second" already spans all
879
+ // minutes, so a wildcard minute is not stated). The leading field is the
880
+ // seconds when it is a wildcard or clean step; otherwise the minute, when the
881
+ // second is a plain :00 and the minute is a wildcard or clean step. A single,
882
+ // range, or list lead is a clock-point form ("at 30 seconds past the minute"),
883
+ // not a cadence, and is left to the existing renderers.
884
+
885
+ // Whether a field token is a wildcard or a clean step (`*/n`) — the two shapes
886
+ // that read as a leading cadence. A bounded step (`a-b/n`) is a windowed set,
887
+ // not a clean day/hour-spanning cadence.
888
+ function isCadenceField(token: string): boolean {
889
+ return token === '*' ||
890
+ token.startsWith('*/') && token.indexOf('-') === -1;
891
+ }
892
+
893
+ // The leading cadence and whether the second is the leading field, or null when
894
+ // the pattern has no cadence lead (the finest restricted field is a clock-point
895
+ // single/range/list). The seconds lead when restricted as a cadence; otherwise
896
+ // the minute leads when the second is a plain :00 and the minute is a cadence.
897
+ function leadingCadence(ir: IR, opts: NormalizedOptions):
898
+ {text: string; secondLead: boolean} | null {
899
+ const {second, minute} = ir.pattern;
900
+
901
+ if (isCadenceField(second)) {
902
+ return {secondLead: true, text: secondsClause(ir, 'minute', opts)};
903
+ }
904
+
905
+ if (second === '0' && isCadenceField(minute)) {
906
+ const text = minute === '*' ?
907
+ 'every minute' :
908
+ // A clean minute step's first segment is a step segment.
909
+ stepCycle60(stepSegment(ir, 'minute'),
910
+ 'minute', 'hour', opts);
911
+
912
+ return {secondLead: false, text};
913
+ }
914
+
915
+ return null;
916
+ }
917
+
918
+ // A pinned minute (single/range/list) under a seconds lead reads as a
919
+ // confinement: "during minute :NN", "during minutes :NN through :MM", "during
920
+ // minutes :NN and :MM". A clean minute step reads "of every other minute". A
921
+ // wildcard minute is redundant under the seconds cadence and drops (empty).
922
+ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
923
+ const minute = ir.pattern.minute;
924
+
925
+ if (minute === '*') {
926
+ return '';
927
+ }
928
+
929
+ if (isCadenceField(minute)) {
930
+ // The gate admits only the `*/2` "every other minute" step here; other
931
+ // minute steps defer to the existing renderer.
932
+ return ' of every other minute';
933
+ }
934
+
935
+ // A minute single/range/list under the seconds lead. The minute reads as a
936
+ // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
937
+ // the minute-lead clock-point form).
938
+ const segments = segmentsOf(ir, 'minute');
939
+
940
+ if (ir.shapes.minute === 'single') {
941
+ return ' during minute :' + pad(minute);
942
+ }
943
+
944
+ if (ir.shapes.minute === 'range') {
945
+ const bounds = minute.split('-');
946
+
947
+ return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
948
+ pad(bounds[1]);
949
+ }
950
+
951
+ const values = segmentWords(segments, opts).map(function colon(word) {
952
+ return ':' + pad(word);
953
+ });
954
+
955
+ return ' during minutes ' + joinList(values, opts);
956
+ }
957
+
958
+ // A restricted hour under a finer cadence reads as a confinement. The form
959
+ // depends on the nearest stated finer field: a stepped minute makes a single
960
+ // hour a span ("from midnight until 1 a.m."); a pinned minute makes it a clock
961
+ // point ("at midnight"); a wildcard/absent minute makes it the hour itself
962
+ // ("of the midnight hour"). A clean hour step is "of every other hour"; a range
963
+ // reuses the until-window; a list or stepped range reads "during the … hours".
964
+ // A wildcard hour drops (empty).
965
+ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
966
+ const hour = ir.pattern.hour;
967
+
968
+ if (hour === '*') {
969
+ // A pinned minute confinement ("during minute :00") repeats across every
970
+ // hour, so the hour is named as the unit of recurrence; a stepped minute
971
+ // ("of every other minute") or absent minute already implies all hours.
972
+ const minutePinned = ir.pattern.minute !== '*' &&
973
+ !isCadenceField(ir.pattern.minute);
974
+
975
+ return minutePinned ? ' of every hour' : '';
976
+ }
977
+
978
+ if (isCadenceField(hour)) {
979
+ return hour === '*/2' ? ' of every other hour' : '';
980
+ }
981
+
982
+ if (ir.shapes.hour === 'single') {
983
+ const h = +hour;
984
+
985
+ if (ir.shapes.minute === 'step') {
986
+ return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
987
+ getTime({hour: (h + 1) % 24, minute: 0}, opts);
988
+ }
989
+
990
+ // A pinned minute confinement already named the minute, so the hour reads
991
+ // as a plain clock point; a wildcard or absent minute makes the hour the
992
+ // unit of recurrence ("of the midnight hour").
993
+ if (ir.pattern.minute !== '*' && !isCadenceField(ir.pattern.minute)) {
994
+ return ' at ' + getTime({hour: h, minute: 0}, opts);
995
+ }
996
+
997
+ return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
998
+ }
999
+
1000
+ if (ir.shapes.hour === 'range') {
1001
+ const bounds = hour.split('-');
1002
+
1003
+ // The until-window holds only when the run is continuous to the top of the
1004
+ // next hour — a wildcard minute fills every minute of the final hour; a
1005
+ // confined minute (":00", a step) stops within it, reading "through".
1006
+ return ' ' + rangeWindow({
1007
+ continuous: ir.pattern.minute === '*',
1008
+ from: +bounds[0],
1009
+ throughMinute: 0,
1010
+ to: +bounds[1]
1011
+ }, opts);
1012
+ }
1013
+
1014
+ // An hour list or stepped range reads "during the <times> hours".
1015
+ return ' during the ' +
1016
+ hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) + ' hours';
1017
+ }
1018
+
1019
+ // Whether the hour field reads as a contiguous window — a real range whose
1020
+ // close depends on the finer field's last fire. A finer STEP cadence does not
1021
+ // fill the closing hour ("from 9 a.m. until 5:45 p.m."), so that window is left
1022
+ // to the existing windowing renderer rather than the confinement frame, which
1023
+ // closes on the top of the next hour ("until 6 p.m.").
1024
+ function isContiguousHourRange(ir: IR): boolean {
1025
+ return ir.shapes.hour === 'range';
1026
+ }
1027
+
1028
+ // Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
1029
+ // `*/n`, an offset `m/n`, or a uneven step — reads as a cadence ("every three
1030
+ // hours from 2 a.m."), and only the `*/2` form has a dedicated confinement
1031
+ // idiom ("of every other hour"), so other open steps defer. A BOUNDED stepped
1032
+ // range (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the
1033
+ // confinement frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
1034
+ function confinableHour(ir: IR): boolean {
1035
+ if (ir.shapes.hour !== 'step') {
1036
+ return true;
1037
+ }
1038
+
1039
+ // Reached only under a stepped hour, whose first segment is a step segment.
1040
+ const segment = stepSegment(ir, 'hour');
1041
+
1042
+ return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
1043
+ }
1044
+
1045
+ // Whether a minute list is really a stride the existing renderer speaks as a
1046
+ // cadence ("every two minutes from 3 through 59"): such a progression is not a
1047
+ // short explicit ":NN" confinement, so it defers.
1048
+ function isMinuteStride(ir: IR): boolean {
1049
+ if (ir.shapes.minute !== 'list') {
1050
+ return false;
1051
+ }
1052
+
1053
+ const values = singleValues(segmentsOf(ir, 'minute'));
1054
+
1055
+ return values !== null && arithmeticStep(values) !== null;
1056
+ }
1057
+
1058
+ // Whether the pattern is in the confinement frame's supported shape-set. The
1059
+ // frame covers a finer leading cadence (seconds, or minute under a :00 second)
1060
+ // with each coarser field as a confinement; shapes outside it defer to the
1061
+ // existing renderers, which already produce that phrasing for them.
1062
+ function confinementEligible(ir: IR,
1063
+ lead: {secondLead: boolean}): boolean {
1064
+ const {minute, hour} = ir.pattern;
1065
+ const minuteStep = isCadenceField(minute) && minute !== '*';
1066
+
1067
+ // A non-`*/2` hour stride keeps the existing cadence form.
1068
+ if (!confinableHour(ir)) {
1069
+ return false;
1070
+ }
1071
+
1072
+ if (lead.secondLead) {
1073
+ // A minute STEP is supported only as the `*/2` "every other minute" idiom,
1074
+ // and only where it fills the coarser field: a contiguous hour range or a
1075
+ // single hour both close on the minute's real last fire, which the
1076
+ // windowing renderer already speaks. The `*/2` step fills both, so it keeps
1077
+ // the "of every other minute" confinement; other steps defer entirely.
1078
+ if (minuteStep) {
1079
+ return minute === '*/2' && !isContiguousHourRange(ir);
1080
+ }
1081
+
1082
+ // A minute list that is really a stride keeps its cadence form; a short
1083
+ // explicit minute list crossed with a discrete hour LIST is a wall of
1084
+ // distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
1085
+ // confinement. Both stay with the enumerating renderer.
1086
+ if (isMinuteStride(ir) ||
1087
+ ir.shapes.minute === 'list' && ir.shapes.hour === 'list') {
1088
+ return false;
1089
+ }
1090
+
1091
+ return true;
1092
+ }
1093
+
1094
+ // A minute-LEAD cadence (second :00). The existing renderers already produce
1095
+ // that phrasing for a single/range/list hour and for a non-`*/2` hour
1096
+ // step; the confinement frame only changes the `*/2` hour ("of every other
1097
+ // hour") and the single hour under an "every other minute" step ("from
1098
+ // midnight until 1 a.m."). Everything else defers.
1099
+ if (hour === '*/2') {
1100
+ return true;
1101
+ }
1102
+
1103
+ return ir.shapes.hour === 'single' && minute === '*/2';
1104
+ }
1105
+
1106
+ // Render the pattern with the confinement frame: a finer leading cadence with
1107
+ // each coarser field as a confinement, or null when it does not apply. Routed
1108
+ // to from the cadence renderers in place of the older juxtaposed-cadence and
1109
+ // duration-frame forms.
1110
+ function confinement(ir: IR, opts: NormalizedOptions): string | null {
1111
+ // The confinement frame is scoped to the default (US) dialect, the one that
1112
+ // carries the until-window; every other dialect and the compact `short` form
1113
+ // keep their established juxtaposed-cadence / duration-frame phrasing.
1114
+ if (!opts.style.untilWindow || opts.short) {
1115
+ return null;
1116
+ }
1117
+
1118
+ // With nothing coarser to confine (minute and hour both wildcard), the bare
1119
+ // cadence renderers already speak the pattern ("every second", "every
1120
+ // minute"); the confinement frame only applies once a coarser field is set.
1121
+ if (ir.pattern.minute === '*' && ir.pattern.hour === '*') {
1122
+ return null;
1123
+ }
1124
+
1125
+ const lead = leadingCadence(ir, opts);
1126
+
1127
+ if (!lead || !confinementEligible(ir, lead)) {
1128
+ return null;
1129
+ }
1130
+
1131
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : '';
1132
+
1133
+ return lead.text + minutePart + hourConfinement(ir, opts) +
1134
+ trailingQualifier(ir, opts);
742
1135
  }
743
1136
 
744
1137
  // The plan dispatch table.
@@ -790,31 +1183,14 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
790
1183
  pluralize(start, unit) + ' past the ' + anchor;
791
1184
  }
792
1185
 
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);
1186
+ // A bounded, non-wrapping set: pin both endpoints. Each bound is a value, so
1187
+ // it reads as a digit, matching the range idiom ("from 0 through 30").
1188
+ const num = seriesNumber();
797
1189
 
798
1190
  return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
799
1191
  pluralize(last, unit) + ' past the ' + anchor;
800
1192
  }
801
1193
 
802
- // The sorted numeric values a field's segments cover, or null if any segment
803
- // is not a discrete single (a range or sub-step is not a plain fire list).
804
- function singleValues(segments: Segment[]): number[] | null {
805
- const values: number[] = [];
806
-
807
- for (const segment of segments) {
808
- if (segment.kind !== 'single') {
809
- return null;
810
- }
811
-
812
- values.push(+segment.value);
813
- }
814
-
815
- return values;
816
- }
817
-
818
1194
  // Speak a minute/second field's enumerated fires as a step cadence when they
819
1195
  // form an arithmetic progression long enough to beat the list (the core
820
1196
  // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
@@ -916,17 +1292,6 @@ function hourStrideCadence(stride: {start: number; interval: number;
916
1292
  through(opts) + getTime({hour: last, minute: 0}, opts);
917
1293
  }
918
1294
 
919
- // Whether an hour stride wraps the day cleanly from within its first interval
920
- // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
921
- // stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
922
- // other stride — a uneven interval, or one starting at or past its interval
923
- // (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
924
- function offsetCleanStride(
925
- stride: {start: number; interval: number}
926
- ): boolean {
927
- return stride.start < stride.interval && 24 % stride.interval === 0;
928
- }
929
-
930
1295
  // The bounded cadence for an hour stride that pins both clock-time endpoints,
931
1296
  // or null when the hour is not such a stride. The core rewrites a uneven step
932
1297
  // to its fire list, so a minute window/list/step crossed with it lands in the
@@ -944,40 +1309,6 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
944
1309
  return hourStrideCadence(stride, opts);
945
1310
  }
946
1311
 
947
- // An hour list's arithmetic progression, or null when its values are not a
948
- // step the renderer should speak as a cadence. The core rewrites a uneven hour
949
- // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
950
- // literal fire list, indistinguishable in the IR from a hand-written list; the
951
- // renderer recovers the cadence from the values. A progression starting at
952
- // zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
953
- // only a step when it is too long to be a deliberate clock-time list (e.g.
954
- // 9,17 is two named times, not a cadence), the same length the minute/second
955
- // list path uses. Interval one is a plain range, never a step.
956
- function hourListStride(values: number[]):
957
- {start: number; interval: number; last: number} | null {
958
- if (values.length < 2) {
959
- return null;
960
- }
961
-
962
- const interval = values[1] - values[0];
963
-
964
- if (interval < 2) {
965
- return null;
966
- }
967
-
968
- for (let i = 2; i < values.length; i += 1) {
969
- if (values[i] - values[i - 1] !== interval) {
970
- return null;
971
- }
972
- }
973
-
974
- if (values[0] !== 0 && values.length < 5) {
975
- return null;
976
- }
977
-
978
- return {interval, last: values[values.length - 1], start: values[0]};
979
- }
980
-
981
1312
  // The hour field's stride, or null when the hour is not a cadence: a step
982
1313
  // segment yields its {start, interval, last} directly; an all-single hour
983
1314
  // list yields one only when its values form a step progression (so an irregular
@@ -988,7 +1319,7 @@ function hourStride(ir: IR):
988
1319
  {start: number; interval: number; last: number} | null {
989
1320
  // Reached only from the clock-time paths, which run under discrete hours
990
1321
  // and so always carry hour segments.
991
- const segments = ir.analyses.segments.hour!;
1322
+ const segments = segmentsOf(ir, 'hour');
992
1323
 
993
1324
  if (segments.length === 1 && segments[0].kind === 'step') {
994
1325
  const segment = segments[0];
@@ -1083,12 +1414,12 @@ function hourCadence(ir: IR, minute: number,
1083
1414
  // minute during every other hour", matching the "every minute during every
1084
1415
  // other hour" idiom and keeping it distinct from the bare hour-step form
1085
1416
  // ("every two hours") so the minute-0 confinement is never heard as it.
1086
- const confinement = minute === 0 && subMinuteSecond(ir) &&
1417
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
1087
1418
  cleanStrideSegment(ir);
1088
1419
 
1089
- if (confinement) {
1420
+ if (minuteZeroStride) {
1090
1421
  return secondsClause(ir, 'minute', opts) + ' for one minute ' +
1091
- everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
1422
+ everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
1092
1423
  }
1093
1424
 
1094
1425
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
@@ -1109,7 +1440,7 @@ function hourCadence(ir: IR, minute: number,
1109
1440
  // or an arithmetic-progression list, which keep the bounded cadence form).
1110
1441
  function cleanStrideSegment(ir: IR): StepSegment | null {
1111
1442
  // Reached only after hourStride confirmed a stride, so hour segments exist.
1112
- const segments = ir.analyses.segments.hour!;
1443
+ const segments = segmentsOf(ir, 'hour');
1113
1444
  const segment = segments.length === 1 && segments[0];
1114
1445
 
1115
1446
  if (!segment || segment.kind !== 'step' ||
@@ -1129,44 +1460,40 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1129
1460
  function hasHourWindow(ir: IR): boolean {
1130
1461
  // Reached only from the clock-time paths, which run under discrete hours
1131
1462
  // and so always carry hour segments.
1132
- return ir.analyses.segments.hour!.some(function range(segment) {
1463
+ return segmentsOf(ir, 'hour').some(function range(segment) {
1133
1464
  return segment.kind === 'range';
1134
1465
  });
1135
1466
  }
1136
1467
 
1137
1468
  // 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.
1469
+ // segment is a window ("every hour from 9 a.m. through 8 p.m."), and any
1470
+ // non-contiguous single hour is appended by `outlierTail` ("and at 10 p.m.").
1471
+ // The minute has already folded into the "every hour" lead a single pinned
1472
+ // minute, never a wildcard so the run is not continuous to the top of the
1473
+ // next hour and the window keeps "through". Mirrors foldedHourWindows but
1474
+ // pinned to minute 0.
1142
1475
  function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1143
1476
  const windows: string[] = [];
1144
- const singles: number[] = [];
1477
+ const outlierHours = collectHourOutliers(ir);
1145
1478
 
1146
1479
  // Reached only after hasHourWindow, so hour segments exist.
1147
- ir.analyses.segments.hour!.forEach(function classify(segment) {
1480
+ segmentsOf(ir, 'hour').forEach(function classify(segment) {
1148
1481
  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);
1482
+ windows.push(rangeWindow({
1483
+ continuous: false,
1484
+ from: +segment.bounds[0],
1485
+ throughMinute: 0,
1486
+ to: +segment.bounds[1]
1487
+ }, opts));
1158
1488
  }
1159
1489
  });
1160
1490
 
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
- }
1491
+ const phrase = 'every hour ' + joinList(windows, opts);
1492
+ const times = outlierHours.map(function time(hour) {
1493
+ return getTime({hour, minute: 0}, opts);
1494
+ });
1168
1495
 
1169
- return phrase;
1496
+ return phrase + outlierTail(times, opts);
1170
1497
  }
1171
1498
 
1172
1499
  // Render an hour range (or a list whose segments include a range) under a
@@ -1211,41 +1538,55 @@ function hourRangeCadence(ir: IR, minute: number,
1211
1538
 
1212
1539
  // --- List and segment phrasing. ---
1213
1540
 
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
-
1541
+ // Number style for the bounds of a "from X through Y" series a range or a
1542
+ // pinned-endpoint stride. The boundary of a range is a clock/calendar VALUE,
1543
+ // not a frequency, so it always reads as a digit ("from 0 through 10", "from 1
1544
+ // through 5"), matching the minutes-/seconds-past convention; only the "every
1545
+ // N" multiplier keeps the spell-when-small style.
1546
+ function seriesNumber(): (n: number | string) => string | number {
1224
1547
  return function format(n) {
1225
- return anyBig ? '' + n : getNumber(n, opts);
1548
+ return '' + n;
1226
1549
  };
1227
1550
  }
1228
1551
 
1229
- // Render numeric fire values as number words, consistent across the set.
1552
+ // The number style for an enumerated set of values: a genuine LIST (two or
1553
+ // more comma-separated values) reads as numerals throughout ("at 4, 6, and 9
1554
+ // minutes past the hour") even when every value is small; a lone value keeps
1555
+ // the dialect's spelled-when-small style ("at five minutes past the hour"),
1556
+ // matching the single-value renderers. The list comma is the cue that pushes
1557
+ // the eye to numerals.
1558
+ function listNumber(count: number, opts: NormalizedOptions):
1559
+ (n: number | string) => string | number {
1560
+ return count > 1 ?
1561
+ function asNumeral(n) {
1562
+ return '' + n;
1563
+ } :
1564
+ function spelled(n) {
1565
+ return getNumber(n, opts);
1566
+ };
1567
+ }
1568
+
1569
+ // Render numeric fire values for an enumerated list: a multi-value list reads
1570
+ // as numerals, a lone value stays spelled (see `listNumber`).
1230
1571
  function numberWords(fires: number[],
1231
1572
  opts: NormalizedOptions): (string | number)[] {
1232
- return fires.map(seriesNumber(fires, opts));
1573
+ return fires.map(listNumber(fires.length, opts));
1233
1574
  }
1234
1575
 
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).
1576
+ // Render classified segments as words for an enumerated list: singles as
1577
+ // numbers, ranges as "<a> through <b>" pairs, step segments as their
1578
+ // enumerated fires. A multi-value list numeralizes throughout; a lone value
1579
+ // keeps the spelled-when-small style (see `listNumber`).
1238
1580
  function segmentWords(segments: Segment[],
1239
1581
  opts: NormalizedOptions): (string | number)[] {
1240
- const values = segments.flatMap(function collect(segment):
1241
- (string | number)[] {
1582
+ const count = segments.reduce(function tally(sum, segment) {
1242
1583
  if (segment.kind === 'range') {
1243
- return segment.bounds;
1584
+ return sum + 1;
1244
1585
  }
1245
1586
 
1246
- return segment.kind === 'step' ? segment.fires : [segment.value];
1247
- });
1248
- const num = seriesNumber(values, opts);
1587
+ return sum + (segment.kind === 'step' ? segment.fires.length : 1);
1588
+ }, 0);
1589
+ const num = listNumber(count, opts);
1249
1590
 
1250
1591
  return segments.flatMap(function word(segment) {
1251
1592
  if (segment.kind === 'range') {
@@ -1298,6 +1639,13 @@ function hourTimes(hours: number[], opts: NormalizedOptions): string {
1298
1639
  return joinList(times, opts);
1299
1640
  }
1300
1641
 
1642
+ // Whether an hour-times plan names exactly one hour. A lone hour is not a
1643
+ // list, so the cadence renderers keep the "at <time>" frame rather than the
1644
+ // plural "during the … hours" confinement.
1645
+ function singleHourFire(times: HourTimesPlan): boolean {
1646
+ return times.kind === 'fires' && times.fires.length === 1;
1647
+ }
1648
+
1301
1649
  // The hour times accompanying a window phrase: enumerated fires up to the
1302
1650
  // cap, segment rendering past it (decided by the core). `atContext` marks
1303
1651
  // an "at <times>" frame (vs "during the <times> hours").
@@ -1329,7 +1677,7 @@ function hourSegmentTimes(ir: IR,
1329
1677
  const {minute, second} = fold;
1330
1678
  // Hour-segment rendering is reached only under discrete hours, which have
1331
1679
  // segments.
1332
- const segments = ir.analyses.segments.hour!;
1680
+ const segments = segmentsOf(ir, 'hour');
1333
1681
  const plain = mixedTwelve(segments.flatMap(function entries(segment) {
1334
1682
  return segmentHours(segment).map(function entry(hour) {
1335
1683
  return {hour: +hour, minute, second};
@@ -1376,22 +1724,35 @@ function disambiguateTimes(pieces: string[], segments: Segment[],
1376
1724
  });
1377
1725
  }
1378
1726
 
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
1727
+ // Join a list with commas and a terminal conjunction. The US dialect (Chicago)
1728
+ // adds a serial comma before the conjunction in lists of three or more; the UK
1381
1729
  // dialect (Guardian) does not. Pairs never take one.
1382
- function joinList(items: (string | number)[],
1730
+ function joinWith(items: (string | number)[], conjunction: string,
1383
1731
  opts: NormalizedOptions): string {
1384
1732
  if (items.length <= 1) {
1385
1733
  return items.join('');
1386
1734
  }
1387
1735
 
1388
1736
  if (items.length === 2) {
1389
- return items[0] + ' and ' + items[1];
1737
+ return items[0] + conjunction + items[1];
1390
1738
  }
1391
1739
 
1392
- const and = opts.style.serialComma ? ', and ' : ' and ';
1740
+ const tail = opts.style.serialComma ? ',' + conjunction : conjunction;
1393
1741
 
1394
- return items.slice(0, -1).join(', ') + and + items[items.length - 1];
1742
+ return items.slice(0, -1).join(', ') + tail + items[items.length - 1];
1743
+ }
1744
+
1745
+ // Join a list with a terminal "and" (the default English connective).
1746
+ function joinList(items: (string | number)[],
1747
+ opts: NormalizedOptions): string {
1748
+ return joinWith(items, ' and ', opts);
1749
+ }
1750
+
1751
+ // Join a list with a terminal "or", for an alternation such as a day-union
1752
+ // predicate list ("the 1st, a Sunday, or a weekday").
1753
+ function joinOr(items: (string | number)[],
1754
+ opts: NormalizedOptions): string {
1755
+ return joinWith(items, ' or ', opts);
1395
1756
  }
1396
1757
 
1397
1758
  // --- Day-level qualifiers. ---
@@ -1405,13 +1766,19 @@ interface QualifierWords {
1405
1766
  month: string;
1406
1767
  stepDate: string;
1407
1768
  weekday: string;
1769
+ // A trailing weekday is a recurring schedule and reads plural ("on
1770
+ // Mondays"); a leading time-anchored one names the day singular ("every
1771
+ // Monday at 9 a.m.").
1772
+ recurringWeekday: boolean;
1408
1773
  }
1409
1774
 
1410
- const trailingWords: QualifierWords =
1411
- {all: '', month: 'in ', stepDate: 'on ', weekday: 'on '};
1775
+ const trailingWords: QualifierWords = {
1776
+ all: '', month: 'in ', recurringWeekday: true, stepDate: 'on ', weekday: 'on '
1777
+ };
1412
1778
  const leadingWords: QualifierWords = {
1413
1779
  all: 'every day',
1414
1780
  month: 'every day in ',
1781
+ recurringWeekday: false,
1415
1782
  stepDate: '',
1416
1783
  weekday: 'every '
1417
1784
  };
@@ -1419,6 +1786,13 @@ const leadingWords: QualifierWords = {
1419
1786
  // A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
1420
1787
  // Returns an empty string when no date, month, or weekday is set.
1421
1788
  function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1789
+ // A day union reframes both day fields as a trailing condition clause; the
1790
+ // month leads the whole description (applied in `describe`), so it is not
1791
+ // part of the trailing qualifier here.
1792
+ if (isDayUnion(ir, opts)) {
1793
+ return dayUnionCondition(ir, opts);
1794
+ }
1795
+
1422
1796
  const phrase = dayQualifier(ir, trailingWords, opts);
1423
1797
 
1424
1798
  return phrase && ' ' + phrase;
@@ -1427,6 +1801,13 @@ function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1427
1801
  // Build the day-level qualifier that precedes a specific time, e.g.
1428
1802
  // "every day ", "every Friday ", or "on January 13 ".
1429
1803
  function interpretDayQualifier(ir: IR, opts: NormalizedOptions): string {
1804
+ // A day union puts the time first ("at midnight whenever the day is …"), so
1805
+ // the leading position contributes no day phrase; the condition clause is
1806
+ // appended after the time by the clock renderer.
1807
+ if (isDayUnion(ir, opts)) {
1808
+ return '';
1809
+ }
1810
+
1430
1811
  return dayQualifier(ir, leadingWords, opts) + ' ';
1431
1812
  }
1432
1813
 
@@ -1450,8 +1831,17 @@ function dayQualifier(ir: IR, words: QualifierWords,
1450
1831
  // A weekday qualifier, optionally scoped to a month ("on Monday in
1451
1832
  // June").
1452
1833
  if (pattern.weekday !== '*') {
1453
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) ||
1454
- words.weekday + weekdayPhrase(ir, opts);
1834
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1835
+
1836
+ // The Quartz weekday phrase ("on the last Friday of the month") carries
1837
+ // the "of the month" recurrence a concrete month makes redundant; a plain
1838
+ // weekday name takes the ordinary " in <month>" scope.
1839
+ if (quartzWeekday) {
1840
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1841
+ }
1842
+
1843
+ const weekdays = words.weekday +
1844
+ weekdayPhrase(ir, words.recurringWeekday, opts);
1455
1845
 
1456
1846
  return weekdays + monthScope(ir, opts);
1457
1847
  }
@@ -1470,11 +1860,12 @@ function datePhrase(ir: IR, words: QualifierWords,
1470
1860
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1471
1861
 
1472
1862
  if (quartzDate) {
1473
- return quartzDate + monthScope(ir, opts);
1863
+ return monthScopeForRecurrence(quartzDate, ir, opts);
1474
1864
  }
1475
1865
 
1476
1866
  if (isOpenStep(pattern.date)) {
1477
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1867
+ return monthScopeForRecurrence(
1868
+ words.stepDate + stepDates(pattern.date), ir, opts);
1478
1869
  }
1479
1870
 
1480
1871
  if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
@@ -1497,11 +1888,153 @@ function datePhrase(ir: IR, words: QualifierWords,
1497
1888
  function monthFoldsIntoDate(ir: IR): boolean {
1498
1889
  return !oddEvenMonth(ir.pattern.month) &&
1499
1890
  // Reached only with a restricted month, which has segments.
1500
- ir.analyses.segments.month!.every(function flat(segment) {
1891
+ segmentsOf(ir, 'month').every(function flat(segment) {
1501
1892
  return segment.kind !== 'range';
1502
1893
  });
1503
1894
  }
1504
1895
 
1896
+ // When BOTH the date and weekday are restricted, cron fires on the UNION of
1897
+ // the two day sets — a point the old "on <dom> or on <dow>" form blurred,
1898
+ // reading as alternatives (or, with "and", as an intersection). The default
1899
+ // dialect reframes the union as a predicate over a single variable, the day:
1900
+ // "whenever the day is <dom-predicate> or <dow-predicate(s)>", a flat or-list
1901
+ // that reads as a union for naive, logical, and technical readers alike. The
1902
+ // month leads the whole clause ("in June …") and the time/cadence sits between
1903
+ // the two, so this form is composed at the top level (see `dayUnionMonthLead`
1904
+ // and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
1905
+ // to the until-window dialect; every other dialect and the `short` form keep
1906
+ // the established "on <dom> or on <dow>" phrasing.
1907
+ function isDayUnion(ir: IR, opts: NormalizedOptions): boolean {
1908
+ return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
1909
+ !!opts.style.untilWindow && !opts.short;
1910
+ }
1911
+
1912
+ // The trailing condition clause for a day union, e.g. " whenever the day is
1913
+ // the 1st or a Friday". The day predicates are flattened into one or-list so
1914
+ // the union reads as a single set of matching days.
1915
+ function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
1916
+ const pieces = [...dayUnionDatePieces(ir, opts),
1917
+ ...dayUnionWeekdayPieces(ir, opts)];
1918
+
1919
+ return ' whenever the day is ' + joinOr(pieces, opts);
1920
+ }
1921
+
1922
+ // The leading "in <month> " scope for a day union, or an empty string when the
1923
+ // month is a wildcard. The month scopes the whole union, so it leads the clause
1924
+ // rather than attaching to either day half.
1925
+ function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
1926
+ if (ir.pattern.month === '*') {
1927
+ return '';
1928
+ }
1929
+
1930
+ return 'in ' + monthName(ir, opts) + ' ';
1931
+ }
1932
+
1933
+ // The day-of-month half of a union as a flat list of predicate pieces. A
1934
+ // Quartz date is its definite phrase ("the last day of the month"); an open
1935
+ // `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
1936
+ // reads each segment as "the <ordinal>" or "from the <ordinal> through the
1937
+ // <ordinal>".
1938
+ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1939
+ const dateField = ir.pattern.date;
1940
+ const quartz = quartzDatePhrase(dateField, opts);
1941
+
1942
+ if (quartz) {
1943
+ return [quartz.replace(/^on /, '')];
1944
+ }
1945
+
1946
+ const oddEven = oddEvenDay(dateField);
1947
+
1948
+ if (oddEven) {
1949
+ return [oddEven];
1950
+ }
1951
+
1952
+ // Reached only with a restricted, non-Quartz date, which has segments. Each
1953
+ // segment contributes its predicate piece(s) to the flat union list; a step
1954
+ // spreads its enumerated fires as separate "the <ordinal>" alternatives.
1955
+ const pieces: string[] = [];
1956
+
1957
+ segmentsOf(ir, 'date').forEach(function expand(segment) {
1958
+ if (segment.kind === 'range') {
1959
+ pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
1960
+ 'the ' + getOrdinal(segment.bounds[1]));
1961
+ }
1962
+ else if (segment.kind === 'step') {
1963
+ segment.fires.forEach(function fire(value) {
1964
+ pieces.push('the ' + getOrdinal(value));
1965
+ });
1966
+ }
1967
+ else {
1968
+ pieces.push('the ' + getOrdinal(segment.value));
1969
+ }
1970
+ });
1971
+
1972
+ return pieces;
1973
+ }
1974
+
1975
+ // The day-of-week half of a union as a flat list of predicate pieces. A Quartz
1976
+ // weekday is its definite phrase ("the last Friday of the month"); the Monday-
1977
+ // through-Friday range is the "a weekday" idiom; every other weekday names each
1978
+ // day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
1979
+ // kind of day the union can match.
1980
+ function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
1981
+ const weekdayField = ir.pattern.weekday;
1982
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
1983
+
1984
+ if (quartz) {
1985
+ return [quartz.replace(/^on /, '')];
1986
+ }
1987
+
1988
+ // The union predicate keeps the canonical Sunday-first order (0…6) rather
1989
+ // than the weekend-last display order: as a flat or-list of day kinds, the
1990
+ // numeric order reads as naturally as any other in a flat or-list ("a
1991
+ // Sunday, a Tuesday, a Thursday, or a Saturday").
1992
+ const pieces: string[] = [];
1993
+
1994
+ segmentsOf(ir, 'weekday').forEach(function expand(segment) {
1995
+ if (segment.kind === 'range' &&
1996
+ segment.bounds[0] === '1' && segment.bounds[1] === '5') {
1997
+ pieces.push('a weekday');
1998
+ }
1999
+ else if (segment.kind === 'range') {
2000
+ pieces.push('a ' + getWeekday(segment.bounds[0], opts) + through(opts) +
2001
+ 'a ' + getWeekday(segment.bounds[1], opts));
2002
+ }
2003
+ else if (segment.kind === 'step') {
2004
+ segment.fires.forEach(function fire(value) {
2005
+ pieces.push('a ' + getWeekday(value, opts));
2006
+ });
2007
+ }
2008
+ else {
2009
+ pieces.push('a ' + getWeekday(segment.value, opts));
2010
+ }
2011
+ });
2012
+
2013
+ return pieces;
2014
+ }
2015
+
2016
+ // An interval-2 day-of-month step covering a parity set reads as "an
2017
+ // odd/even-numbered day", mirroring the month and year parity idioms: `*/2`
2018
+ // and `1/2` are the odd days, `2/2` the even; any other start enumerates
2019
+ // instead. Null when the field is not an open interval-2 step.
2020
+ function oddEvenDay(dateField: string): string | null {
2021
+ if (!isOpenStep(dateField)) {
2022
+ return null;
2023
+ }
2024
+
2025
+ const [start, step] = dateField.split('/');
2026
+
2027
+ if (+step !== 2) {
2028
+ return null;
2029
+ }
2030
+
2031
+ if (start === '*' || start === '1') {
2032
+ return 'an odd-numbered day';
2033
+ }
2034
+
2035
+ return start === '2' ? 'an even-numbered day' : null;
2036
+ }
2037
+
1505
2038
  // Compose the "day-of-month or day-of-week" phrase used when both fields
1506
2039
  // are restricted: cron fires when either is a match. A restricted month
1507
2040
  // scopes BOTH halves, so it attaches to the whole or, never to a single
@@ -1511,8 +2044,10 @@ function monthFoldsIntoDate(ir: IR): boolean {
1511
2044
  // odd/even frequency) it trails the whole or as ", in <month>".
1512
2045
  function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
1513
2046
  const pattern = ir.pattern;
2047
+ // The day-of-month-OR-day-of-week union is out of scope for the recurring
2048
+ // plural (it is reframed elsewhere): the weekday half stays singular here.
1514
2049
  const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
1515
- 'on ' + weekdayPhrase(ir, opts);
2050
+ 'on ' + weekdayPhrase(ir, false, opts);
1516
2051
 
1517
2052
  if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
1518
2053
  !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
@@ -1599,13 +2134,24 @@ function quartzWeekdayPhrase(weekdayField: string,
1599
2134
  // A calendar date with its month, in the dialect's order and day form:
1600
2135
  // cardinal "January 1" / "1 January", or ordinal "January 1st" for
1601
2136
  // dialects that set `ordinals`.
2137
+ //
2138
+ // A day-first dialect places the day before the month, but a single day before
2139
+ // a MULTI-month list garden-paths — "13 January, April, July and October"
2140
+ // reads as if the 13 belongs to January alone. The day is reattached to the
2141
+ // whole list with the possessive "the <ordinal> of <months>", which names the
2142
+ // same day across every month unambiguously.
1602
2143
  function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
1603
2144
  const month = monthName(ir, opts);
1604
2145
  // A month-day phrase is reached only with a restricted date, which has
1605
2146
  // segments.
1606
- const days = renderSegments(ir.analyses.segments.date!,
2147
+ const days = renderSegments(segmentsOf(ir, 'date'),
1607
2148
  opts.style.ordinals ? getOrdinal : cardinalDay, opts);
1608
2149
 
2150
+ if (opts.style.dayFirst && ir.shapes.date === 'single' &&
2151
+ ir.shapes.month !== 'single') {
2152
+ return 'the ' + getOrdinal(ir.pattern.date) + ' of ' + month;
2153
+ }
2154
+
1609
2155
  return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
1610
2156
  }
1611
2157
 
@@ -1624,6 +2170,35 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
1624
2170
  return ' in ' + monthName(ir, opts);
1625
2171
  }
1626
2172
 
2173
+ // Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
2174
+ // day / last-weekday / nth-weekday forms and the open day-of-month step) by a
2175
+ // named month. A concrete month — a single name or a step ("every odd-numbered
2176
+ // month", "January, April, …") — makes "of the month" redundant: it names that
2177
+ // one month, so the phrase drops it and reads "in <month>". A month RANGE
2178
+ // distributes the recurrence across the span and keeps it, rephrased as "of
2179
+ // each month from <first> through <last>". A month list is left as-is (the
2180
+ // recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
2181
+ function monthScopeForRecurrence(phrase: string, ir: IR,
2182
+ opts: NormalizedOptions): string {
2183
+ if (ir.pattern.month === '*') {
2184
+ return phrase;
2185
+ }
2186
+
2187
+ const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
2188
+
2189
+ if (carriesRecurrence && ir.shapes.month === 'range') {
2190
+ return phrase.replace(' of the month', ' of each month') + ' from ' +
2191
+ monthName(ir, opts);
2192
+ }
2193
+
2194
+ if (carriesRecurrence &&
2195
+ (ir.shapes.month === 'single' || ir.shapes.month === 'step')) {
2196
+ return phrase.replace(' of the month', '') + ' in ' + monthName(ir, opts);
2197
+ }
2198
+
2199
+ return phrase + ' in ' + monthName(ir, opts);
2200
+ }
2201
+
1627
2202
  // Frequency phrase for an open day-of-month step, e.g. "every other day of
1628
2203
  // the month" or "every 3rd day of the month from the 5th".
1629
2204
  function stepDates(dateField: string): string {
@@ -1646,7 +2221,7 @@ function stepDates(dateField: string): string {
1646
2221
  // handled separately as a frequency phrase.
1647
2222
  function dateOrdinals(ir: IR, opts: NormalizedOptions): string {
1648
2223
  // Reached only with a restricted date, which has segments.
1649
- return renderSegments(ir.analyses.segments.date!, getOrdinal, opts);
2224
+ return renderSegments(segmentsOf(ir, 'date'), getOrdinal, opts);
1650
2225
  }
1651
2226
 
1652
2227
  // Render the month field as names. There are few, named months, so a step
@@ -1662,7 +2237,7 @@ function monthName(ir: IR, opts: NormalizedOptions): string {
1662
2237
 
1663
2238
  // A restricted month has segments; open steps of interval 3+ enumerate their
1664
2239
  // fires here too.
1665
- return renderSegments(ir.analyses.segments.month!, function name(value) {
2240
+ return renderSegments(segmentsOf(ir, 'month'), function name(value) {
1666
2241
  return getMonth(value, opts);
1667
2242
  }, opts);
1668
2243
  }
@@ -1690,16 +2265,44 @@ function oddEvenMonth(monthField: string): string | null {
1690
2265
  }
1691
2266
 
1692
2267
  // 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 {
2268
+ // ("Monday through Friday", or "Mon-Fri" with `short`). When `recurring`, a
2269
+ // trailing single or list weekday is a repeating schedule and reads plural
2270
+ // ("on Mondays", "on Mondays and Wednesdays"), matching es/de/fi; a RANGE
2271
+ // keeps the singular idiom ("on Monday through Friday") so its through-
2272
+ // connective stays unmistakable, and a leading time-anchored form ("every
2273
+ // Monday") is never recurring here.
2274
+ function weekdayPhrase(ir: IR, recurring: boolean,
2275
+ opts: NormalizedOptions): string {
1695
2276
  // Reached only with a restricted weekday, which has segments. Weekday lists
1696
2277
  // display Monday-first (Sunday last) so a weekend reads naturally; the IR
1697
2278
  // stays canonical (Sunday=0) and ranges keep their form.
1698
- const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
2279
+ const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
2280
+ const hasRange = segments.some(function range(segment) {
2281
+ return segment.kind === 'range';
2282
+ });
1699
2283
 
1700
- return renderSegments(segments, function name(value) {
1701
- return getWeekday(value, opts);
1702
- }, opts);
2284
+ // A range pins the singular idiom for the whole phrase ("Monday through
2285
+ // Friday"); only an all-single/step set pluralizes its names.
2286
+ const name = recurring && !hasRange ?
2287
+ function plural(value: number | string): string {
2288
+ return pluralWeekday(value, opts);
2289
+ } :
2290
+ function singular(value: number | string): string {
2291
+ return getWeekday(value, opts);
2292
+ };
2293
+
2294
+ return renderSegments(segments, name, opts);
2295
+ }
2296
+
2297
+ // The recurring (plural) form of a weekday name: every English weekday name
2298
+ // pluralizes by appending "s" ("Mondays", "Sundays"). The `short`
2299
+ // abbreviation keeps its singular form — "on Mons" reads as an error, not a
2300
+ // plural.
2301
+ function pluralWeekday(value: number | string,
2302
+ opts: NormalizedOptions): string {
2303
+ const name = getWeekday(value, opts);
2304
+
2305
+ return opts.short ? name : name + 's';
1703
2306
  }
1704
2307
 
1705
2308
  // Render classified field segments with `word`, expanding step segments
@@ -1725,14 +2328,6 @@ function renderSegments(segments: Segment[],
1725
2328
  return joinList(pieces, opts);
1726
2329
  }
1727
2330
 
1728
- // Whether a canonical field value is an "open" step (`*/n` or `a/n`, not a
1729
- // bounded range or a list). Open steps read as a frequency rather than an
1730
- // enumeration.
1731
- function isOpenStep(field: string): boolean {
1732
- return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
1733
- field.indexOf(',') === -1;
1734
- }
1735
-
1736
2331
  // --- Years. ---
1737
2332
 
1738
2333
  // Append or fold the year field into a finished description. An
@@ -1746,7 +2341,10 @@ function applyYear(description: string, ir: IR,
1746
2341
  }
1747
2342
 
1748
2343
  if (yearField.indexOf('/') !== -1) {
1749
- return description + ' ' + stepYears(yearField, opts);
2344
+ // A year step is a coarser cadence juxtaposed on the finished clause: a
2345
+ // clause comma separates it ("every second, every other year"), matching
2346
+ // how every other juxtaposed clause is joined.
2347
+ return description + ', ' + stepYears(yearField, opts);
1750
2348
  }
1751
2349
 
1752
2350
  const label = yearLabel(yearField, opts);
@@ -1769,6 +2367,12 @@ function yearLabel(yearField: string, opts: NormalizedOptions): string {
1769
2367
  return joinList(yearField.split(','), opts);
1770
2368
  }
1771
2369
 
2370
+ // A year range reads with the dialect's range connective ("2030 through
2371
+ // 2035"), the same form every other field uses, not a raw hyphen.
2372
+ if (yearField.indexOf('-') !== -1) {
2373
+ return yearField.split('-').join(through(opts));
2374
+ }
2375
+
1772
2376
  return yearField;
1773
2377
  }
1774
2378
 
@@ -1783,7 +2387,11 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1783
2387
  return 'every year';
1784
2388
  }
1785
2389
 
1786
- let phrase = 'every ' + getNumber(interval, opts) + ' years';
2390
+ // Interval 2 reads as the parity idiom ("every other year"), matching the
2391
+ // month and day-of-month step forms; longer intervals count the years.
2392
+ let phrase = interval === 2 ?
2393
+ 'every other year' :
2394
+ 'every ' + getNumber(interval, opts) + ' years';
1787
2395
 
1788
2396
  if (start !== '*' && start !== '0') {
1789
2397
  phrase += ' from ' + start;