cronli5 0.2.0 → 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,7 +3,11 @@
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
12
  import {clockDigits, numeral, pad} from '../../core/format.js';
9
13
  import type {Cronli5Options} from '../../types.js';
@@ -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
@@ -215,9 +230,6 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
215
230
  return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
216
231
  }
217
232
 
218
- // A meaningful second under minute/hour shapes the earlier strategies
219
- // deferred on: the second leads with its own clause and the rest of the
220
- // pattern follows.
221
233
  // A wildcard or stepped second under a fixed minute across one or more specific
222
234
  // hours. The clock-time rest collapses the pinned minute into the hour, and on
223
235
  // the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
@@ -340,7 +352,7 @@ function secondsClause(ir: IR, anchor: string,
340
352
  if (shape === 'step') {
341
353
  // The plan reached this clause only for a stepped second field, whose
342
354
  // first segment is always a step segment.
343
- return stepCycle60(ir.analyses.segments.second![0] as StepSegment,
355
+ return stepCycle60(stepSegment(ir, 'second'),
344
356
  'second', anchor, opts);
345
357
  }
346
358
 
@@ -360,8 +372,8 @@ function secondsClause(ir: IR, anchor: string,
360
372
  // A non-wildcard second under the list/step path always has segments. An
361
373
  // offset/uneven step the core enumerated to a fire list reads as a stride
362
374
  // cadence when those fires form a long-enough progression.
363
- return strideFromSegments(ir.analyses.segments.second!, 'second', anchor,
364
- opts) ?? listPastThe(segmentWords(ir.analyses.segments.second!, opts),
375
+ return strideFromSegments(segmentsOf(ir, 'second'), 'second', anchor,
376
+ opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'second'), opts),
365
377
  'second', anchor, opts);
366
378
  }
367
379
 
@@ -393,9 +405,9 @@ function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
393
405
  // segments. An offset/uneven step the core enumerated to this list reads as
394
406
  // a stride cadence when the fires form a long-enough progression.
395
407
  const stride =
396
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts);
408
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts);
397
409
 
398
- return (stride ?? listPastThe(segmentWords(ir.analyses.segments.minute!,
410
+ return (stride ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'),
399
411
  opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
400
412
  }
401
413
 
@@ -404,7 +416,7 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
404
416
  opts: NormalizedOptions): string {
405
417
  // A minute-frequency plan is selected only for a stepped minute field,
406
418
  // which has segments.
407
- let phrase = stepCycle60(ir.analyses.segments.minute![0] as StepSegment,
419
+ let phrase = stepCycle60(stepSegment(ir, 'minute'),
408
420
  'minute', 'hour', opts);
409
421
 
410
422
  if (plan.hours.kind === 'during') {
@@ -419,14 +431,23 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
419
431
  hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
420
432
  }
421
433
  else if (plan.hours.kind === 'window') {
422
- 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);
423
444
  }
424
445
  else if (plan.hours.kind === 'step') {
425
446
  // The plan carries a step only for a clean stride (dividing the day),
426
447
  // which confines the cadence to every Nth hour; a stepped hour field's
427
448
  // first segment is a step segment.
428
449
  phrase += ' ' +
429
- everyNthHour(ir.analyses.segments.hour![0] as StepSegment, opts);
450
+ everyNthHour(stepSegment(ir, 'hour'), opts);
430
451
  }
431
452
 
432
453
  return phrase + trailingQualifier(ir, opts);
@@ -497,8 +518,8 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
497
518
  // step enumerated to that list reads as a stride. A list is a set of
498
519
  // discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
499
520
  const lead =
500
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
501
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
521
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
522
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
502
523
  'minute', 'hour', opts);
503
524
 
504
525
  if (cadence !== null) {
@@ -534,7 +555,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
534
555
  plan: PlanOf<'minuteSpanAcrossHourStep'>, opts: NormalizedOptions): string {
535
556
  // This plan is reached only under a stepped hour field, whose first
536
557
  // segment is a step segment.
537
- const segment = ir.analyses.segments.hour![0] as StepSegment;
558
+ const segment = stepSegment(ir, 'hour');
538
559
 
539
560
  // A wildcard minute over a stepped hour is reached only for a clean stride
540
561
  // (a bounded or uneven step routes through minutesAcrossHours instead), so it
@@ -547,8 +568,8 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
547
568
  // A minute list keeps the same cadence clause; only its lead differs. An
548
569
  // offset/uneven step the core enumerated to that list reads as a stride.
549
570
  const lead = plan.form === 'list' ?
550
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
551
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
571
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
572
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
552
573
  'minute', 'hour', opts) :
553
574
  minuteRangeLead(ir.pattern.minute, opts);
554
575
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
@@ -607,8 +628,8 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
607
628
 
608
629
  // A non-"0" minute here is a discrete list, which has segments; an
609
630
  // offset/uneven step enumerated to that list reads as a stride.
610
- return strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour',
611
- opts) ?? listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
631
+ return strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour',
632
+ opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
612
633
  'minute', 'hour', opts);
613
634
  }
614
635
 
@@ -625,7 +646,7 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
625
646
 
626
647
  // An hour-step plan is selected only for a stepped hour field, whose
627
648
  // first segment is a step segment.
628
- return stepHours(ir.analyses.segments.hour![0] as StepSegment, opts) +
649
+ return stepHours(stepSegment(ir, 'hour'), opts) +
629
650
  trailingQualifier(ir, opts);
630
651
  }
631
652
 
@@ -634,31 +655,42 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
634
655
  // a wildcard minute, which fills every minute and states no separate clause.
635
656
  // A pinned/listed/ranged minute is named in its own lead clause, so folding it
636
657
  // into the close too would read as a span ("through 5:05 p.m.") that
637
- // 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`.
638
661
  function boundedWindow(plan: PlanOf<'hourRange'>):
639
- {from: number; to: number; last: number} {
640
- 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;
641
665
 
642
- return {from: plan.from, last, to: plan.to};
666
+ return {from: plan.from, closeMinute, to: plan.to, continuous};
643
667
  }
644
668
 
645
669
  // 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,
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,
656
686
  opts: NormalizedOptions): string {
687
+ const {from, to, throughMinute, continuous} = window;
657
688
  const open = 'from ' + getTime({hour: from, minute: 0}, opts);
658
689
 
659
690
  if (opts.style.untilWindow && !opts.short && from !== to) {
660
- return open + ' until ' +
661
- getTime({hour: (to + 1) % 24, minute: 0}, opts);
691
+ return continuous ?
692
+ open + ' until ' + getTime({hour: (to + 1) % 24, minute: 0}, opts) :
693
+ open + through(opts) + getTime({hour: to, minute: 0}, opts);
662
694
  }
663
695
 
664
696
  return open + through(opts) +
@@ -666,11 +698,18 @@ function rangeWindow(from: number, to: number, throughMinute: number | string,
666
698
  }
667
699
 
668
700
  // 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.
671
- function hourWindow(window: {from: number; to: number; last: number},
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},
672
706
  opts: NormalizedOptions): string {
673
- return rangeWindow(window.from, window.to, window.last, opts);
707
+ return rangeWindow({
708
+ continuous: window.continuous,
709
+ from: window.from,
710
+ throughMinute: window.closeMinute,
711
+ to: window.to
712
+ }, opts);
674
713
  }
675
714
 
676
715
  // Expand a discrete set of hours and minutes into clock times prefixed by
@@ -729,7 +768,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
729
768
 
730
769
  // A compact clock-time plan is reached only for discrete hours, which
731
770
  // have segments.
732
- const hasRange = ir.analyses.segments.hour!.some(function range(segment) {
771
+ const hasRange = segmentsOf(ir, 'hour').some(function range(segment) {
733
772
  return segment.kind === 'range';
734
773
  });
735
774
 
@@ -748,8 +787,8 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
748
787
  const minuteLead =
749
788
  // The non-fold branch is a minute list, which has segments. An
750
789
  // offset/uneven step enumerated to that list reads as a stride.
751
- strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
752
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
790
+ strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
791
+ listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
753
792
  'minute', 'hour', opts);
754
793
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
755
794
  // of clock-time columns.
@@ -770,72 +809,63 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
770
809
  // A folded hour field that includes a contiguous range reads with the
771
810
  // hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
772
811
  // 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").
812
+ // appended by `outlierTail` ("and at Z").
775
813
  function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
776
814
  opts: NormalizedOptions): string {
777
815
  const minute = plan.minute;
778
816
  const windows: string[] = [];
779
- const outliers = collectHourOutliers(ir);
780
- const times = outliers.hours.map(function time(hour) {
817
+ const times = collectHourOutliers(ir).map(function time(hour) {
781
818
  return getTime({hour, minute}, opts);
782
819
  });
783
820
 
784
- // Reached only via the fold branch under discrete hours, which have
785
- // segments.
786
- 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) {
787
825
  if (segment.kind === 'range') {
788
- windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1],
789
- minute, opts));
826
+ windows.push(rangeWindow({
827
+ continuous: false,
828
+ from: +segment.bounds[0],
829
+ throughMinute: minute,
830
+ to: +segment.bounds[1]
831
+ }, opts));
790
832
  }
791
833
  });
792
834
 
793
835
  const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
794
836
 
795
- return phrase + outlierTail(times, outliers.pureStrays, opts);
837
+ return phrase + outlierTail(times, opts);
796
838
  }
797
839
 
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} {
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[] {
804
843
  const hours: number[] = [];
805
- let pureStrays = true;
806
844
 
807
845
  // Reached only under discrete hours, which carry segments.
808
- ir.analyses.segments.hour!.forEach(function classify(segment) {
846
+ segmentsOf(ir, 'hour').forEach(function classify(segment) {
809
847
  if (segment.kind === 'step') {
810
848
  hours.push(...segment.fires);
811
- pureStrays = false;
812
849
  }
813
850
  else if (segment.kind !== 'range') {
814
851
  hours.push(+segment.value);
815
852
  }
816
853
  });
817
854
 
818
- return {hours, pureStrays};
855
+ return hours;
819
856
  }
820
857
 
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 {
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 {
830
864
  if (!times.length) {
831
865
  return '';
832
866
  }
833
867
 
834
- const connector = pureStrays && opts.style.untilWindow && !opts.short ?
835
- ' plus ' :
836
- ' and at ';
837
-
838
- return connector + joinList(times, opts);
868
+ return ' and at ' + joinList(times, opts);
839
869
  }
840
870
 
841
871
  // --- Confinement frame. ---
@@ -876,7 +906,7 @@ function leadingCadence(ir: IR, opts: NormalizedOptions):
876
906
  const text = minute === '*' ?
877
907
  'every minute' :
878
908
  // A clean minute step's first segment is a step segment.
879
- stepCycle60(ir.analyses.segments.minute![0] as StepSegment,
909
+ stepCycle60(stepSegment(ir, 'minute'),
880
910
  'minute', 'hour', opts);
881
911
 
882
912
  return {secondLead: false, text};
@@ -905,7 +935,7 @@ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
905
935
  // A minute single/range/list under the seconds lead. The minute reads as a
906
936
  // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
907
937
  // the minute-lead clock-point form).
908
- const segments = ir.analyses.segments.minute!;
938
+ const segments = segmentsOf(ir, 'minute');
909
939
 
910
940
  if (ir.shapes.minute === 'single') {
911
941
  return ' during minute :' + pad(minute);
@@ -970,7 +1000,15 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
970
1000
  if (ir.shapes.hour === 'range') {
971
1001
  const bounds = hour.split('-');
972
1002
 
973
- return ' ' + rangeWindow(+bounds[0], +bounds[1], 0, opts);
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);
974
1012
  }
975
1013
 
976
1014
  // An hour list or stepped range reads "during the <times> hours".
@@ -989,17 +1027,17 @@ function isContiguousHourRange(ir: IR): boolean {
989
1027
 
990
1028
  // Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
991
1029
  // `*/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").
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").
996
1034
  function confinableHour(ir: IR): boolean {
997
1035
  if (ir.shapes.hour !== 'step') {
998
1036
  return true;
999
1037
  }
1000
1038
 
1001
1039
  // Reached only under a stepped hour, whose first segment is a step segment.
1002
- const segment = ir.analyses.segments.hour![0] as StepSegment;
1040
+ const segment = stepSegment(ir, 'hour');
1003
1041
 
1004
1042
  return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
1005
1043
  }
@@ -1012,15 +1050,15 @@ function isMinuteStride(ir: IR): boolean {
1012
1050
  return false;
1013
1051
  }
1014
1052
 
1015
- const values = singleValues(ir.analyses.segments.minute!);
1053
+ const values = singleValues(segmentsOf(ir, 'minute'));
1016
1054
 
1017
1055
  return values !== null && arithmeticStep(values) !== null;
1018
1056
  }
1019
1057
 
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.
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.
1024
1062
  function confinementEligible(ir: IR,
1025
1063
  lead: {secondLead: boolean}): boolean {
1026
1064
  const {minute, hour} = ir.pattern;
@@ -1032,7 +1070,7 @@ function confinementEligible(ir: IR,
1032
1070
  }
1033
1071
 
1034
1072
  if (lead.secondLead) {
1035
- // A minute STEP is blessed only as the `*/2` "every other minute" idiom,
1073
+ // A minute STEP is supported only as the `*/2` "every other minute" idiom,
1036
1074
  // and only where it fills the coarser field: a contiguous hour range or a
1037
1075
  // single hour both close on the minute's real last fire, which the
1038
1076
  // windowing renderer already speaks. The `*/2` step fills both, so it keeps
@@ -1054,7 +1092,7 @@ function confinementEligible(ir: IR,
1054
1092
  }
1055
1093
 
1056
1094
  // 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
1095
+ // that phrasing for a single/range/list hour and for a non-`*/2` hour
1058
1096
  // step; the confinement frame only changes the `*/2` hour ("of every other
1059
1097
  // hour") and the single hour under an "every other minute" step ("from
1060
1098
  // midnight until 1 a.m."). Everything else defers.
@@ -1065,9 +1103,10 @@ function confinementEligible(ir: IR,
1065
1103
  return ir.shapes.hour === 'single' && minute === '*/2';
1066
1104
  }
1067
1105
 
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.
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.
1071
1110
  function confinement(ir: IR, opts: NormalizedOptions): string | null {
1072
1111
  // The confinement frame is scoped to the default (US) dialect, the one that
1073
1112
  // carries the until-window; every other dialect and the compact `short` form
@@ -1152,22 +1191,6 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
1152
1191
  pluralize(last, unit) + ' past the ' + anchor;
1153
1192
  }
1154
1193
 
1155
- // The sorted numeric values a field's segments cover, or null if any segment
1156
- // is not a discrete single (a range or sub-step is not a plain fire list).
1157
- function singleValues(segments: Segment[]): number[] | null {
1158
- const values: number[] = [];
1159
-
1160
- for (const segment of segments) {
1161
- if (segment.kind !== 'single') {
1162
- return null;
1163
- }
1164
-
1165
- values.push(+segment.value);
1166
- }
1167
-
1168
- return values;
1169
- }
1170
-
1171
1194
  // Speak a minute/second field's enumerated fires as a step cadence when they
1172
1195
  // form an arithmetic progression long enough to beat the list (the core
1173
1196
  // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
@@ -1269,17 +1292,6 @@ function hourStrideCadence(stride: {start: number; interval: number;
1269
1292
  through(opts) + getTime({hour: last, minute: 0}, opts);
1270
1293
  }
1271
1294
 
1272
- // Whether an hour stride wraps the day cleanly from within its first interval
1273
- // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1274
- // stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
1275
- // other stride — a uneven interval, or one starting at or past its interval
1276
- // (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1277
- function offsetCleanStride(
1278
- stride: {start: number; interval: number}
1279
- ): boolean {
1280
- return stride.start < stride.interval && 24 % stride.interval === 0;
1281
- }
1282
-
1283
1295
  // The bounded cadence for an hour stride that pins both clock-time endpoints,
1284
1296
  // or null when the hour is not such a stride. The core rewrites a uneven step
1285
1297
  // to its fire list, so a minute window/list/step crossed with it lands in the
@@ -1297,40 +1309,6 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1297
1309
  return hourStrideCadence(stride, opts);
1298
1310
  }
1299
1311
 
1300
- // An hour list's arithmetic progression, or null when its values are not a
1301
- // step the renderer should speak as a cadence. The core rewrites a uneven hour
1302
- // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
1303
- // literal fire list, indistinguishable in the IR from a hand-written list; the
1304
- // renderer recovers the cadence from the values. A progression starting at
1305
- // zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
1306
- // only a step when it is too long to be a deliberate clock-time list (e.g.
1307
- // 9,17 is two named times, not a cadence), the same length the minute/second
1308
- // list path uses. Interval one is a plain range, never a step.
1309
- function hourListStride(values: number[]):
1310
- {start: number; interval: number; last: number} | null {
1311
- if (values.length < 2) {
1312
- return null;
1313
- }
1314
-
1315
- const interval = values[1] - values[0];
1316
-
1317
- if (interval < 2) {
1318
- return null;
1319
- }
1320
-
1321
- for (let i = 2; i < values.length; i += 1) {
1322
- if (values[i] - values[i - 1] !== interval) {
1323
- return null;
1324
- }
1325
- }
1326
-
1327
- if (values[0] !== 0 && values.length < 5) {
1328
- return null;
1329
- }
1330
-
1331
- return {interval, last: values[values.length - 1], start: values[0]};
1332
- }
1333
-
1334
1312
  // The hour field's stride, or null when the hour is not a cadence: a step
1335
1313
  // segment yields its {start, interval, last} directly; an all-single hour
1336
1314
  // list yields one only when its values form a step progression (so an irregular
@@ -1341,7 +1319,7 @@ function hourStride(ir: IR):
1341
1319
  {start: number; interval: number; last: number} | null {
1342
1320
  // Reached only from the clock-time paths, which run under discrete hours
1343
1321
  // and so always carry hour segments.
1344
- const segments = ir.analyses.segments.hour!;
1322
+ const segments = segmentsOf(ir, 'hour');
1345
1323
 
1346
1324
  if (segments.length === 1 && segments[0].kind === 'step') {
1347
1325
  const segment = segments[0];
@@ -1462,7 +1440,7 @@ function hourCadence(ir: IR, minute: number,
1462
1440
  // or an arithmetic-progression list, which keep the bounded cadence form).
1463
1441
  function cleanStrideSegment(ir: IR): StepSegment | null {
1464
1442
  // Reached only after hourStride confirmed a stride, so hour segments exist.
1465
- const segments = ir.analyses.segments.hour!;
1443
+ const segments = segmentsOf(ir, 'hour');
1466
1444
  const segment = segments.length === 1 && segments[0];
1467
1445
 
1468
1446
  if (!segment || segment.kind !== 'step' ||
@@ -1482,35 +1460,40 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1482
1460
  function hasHourWindow(ir: IR): boolean {
1483
1461
  // Reached only from the clock-time paths, which run under discrete hours
1484
1462
  // and so always carry hour segments.
1485
- return ir.analyses.segments.hour!.some(function range(segment) {
1463
+ return segmentsOf(ir, 'hour').some(function range(segment) {
1486
1464
  return segment.kind === 'range';
1487
1465
  });
1488
1466
  }
1489
1467
 
1490
1468
  // The hour-range window as a cadence tail at the top of each hour: each range
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.
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.
1496
1475
  function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1497
1476
  const windows: string[] = [];
1498
- const outliers = collectHourOutliers(ir);
1477
+ const outlierHours = collectHourOutliers(ir);
1499
1478
 
1500
1479
  // Reached only after hasHourWindow, so hour segments exist.
1501
- ir.analyses.segments.hour!.forEach(function classify(segment) {
1480
+ segmentsOf(ir, 'hour').forEach(function classify(segment) {
1502
1481
  if (segment.kind === 'range') {
1503
- windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1], 0,
1504
- opts));
1482
+ windows.push(rangeWindow({
1483
+ continuous: false,
1484
+ from: +segment.bounds[0],
1485
+ throughMinute: 0,
1486
+ to: +segment.bounds[1]
1487
+ }, opts));
1505
1488
  }
1506
1489
  });
1507
1490
 
1508
1491
  const phrase = 'every hour ' + joinList(windows, opts);
1509
- const times = outliers.hours.map(function time(hour) {
1492
+ const times = outlierHours.map(function time(hour) {
1510
1493
  return getTime({hour, minute: 0}, opts);
1511
1494
  });
1512
1495
 
1513
- return phrase + outlierTail(times, outliers.pureStrays, opts);
1496
+ return phrase + outlierTail(times, opts);
1514
1497
  }
1515
1498
 
1516
1499
  // Render an hour range (or a list whose segments include a range) under a
@@ -1694,7 +1677,7 @@ function hourSegmentTimes(ir: IR,
1694
1677
  const {minute, second} = fold;
1695
1678
  // Hour-segment rendering is reached only under discrete hours, which have
1696
1679
  // segments.
1697
- const segments = ir.analyses.segments.hour!;
1680
+ const segments = segmentsOf(ir, 'hour');
1698
1681
  const plain = mixedTwelve(segments.flatMap(function entries(segment) {
1699
1682
  return segmentHours(segment).map(function entry(hour) {
1700
1683
  return {hour: +hour, minute, second};
@@ -1905,7 +1888,7 @@ function datePhrase(ir: IR, words: QualifierWords,
1905
1888
  function monthFoldsIntoDate(ir: IR): boolean {
1906
1889
  return !oddEvenMonth(ir.pattern.month) &&
1907
1890
  // Reached only with a restricted month, which has segments.
1908
- ir.analyses.segments.month!.every(function flat(segment) {
1891
+ segmentsOf(ir, 'month').every(function flat(segment) {
1909
1892
  return segment.kind !== 'range';
1910
1893
  });
1911
1894
  }
@@ -1971,7 +1954,7 @@ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1971
1954
  // spreads its enumerated fires as separate "the <ordinal>" alternatives.
1972
1955
  const pieces: string[] = [];
1973
1956
 
1974
- ir.analyses.segments.date!.forEach(function expand(segment) {
1957
+ segmentsOf(ir, 'date').forEach(function expand(segment) {
1975
1958
  if (segment.kind === 'range') {
1976
1959
  pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
1977
1960
  'the ' + getOrdinal(segment.bounds[1]));
@@ -2004,11 +1987,11 @@ function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
2004
1987
 
2005
1988
  // The union predicate keeps the canonical Sunday-first order (0…6) rather
2006
1989
  // 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").
1990
+ // numeric order reads as naturally as any other in a flat or-list ("a
1991
+ // Sunday, a Tuesday, a Thursday, or a Saturday").
2009
1992
  const pieces: string[] = [];
2010
1993
 
2011
- ir.analyses.segments.weekday!.forEach(function expand(segment) {
1994
+ segmentsOf(ir, 'weekday').forEach(function expand(segment) {
2012
1995
  if (segment.kind === 'range' &&
2013
1996
  segment.bounds[0] === '1' && segment.bounds[1] === '5') {
2014
1997
  pieces.push('a weekday');
@@ -2161,7 +2144,7 @@ function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
2161
2144
  const month = monthName(ir, opts);
2162
2145
  // A month-day phrase is reached only with a restricted date, which has
2163
2146
  // segments.
2164
- const days = renderSegments(ir.analyses.segments.date!,
2147
+ const days = renderSegments(segmentsOf(ir, 'date'),
2165
2148
  opts.style.ordinals ? getOrdinal : cardinalDay, opts);
2166
2149
 
2167
2150
  if (opts.style.dayFirst && ir.shapes.date === 'single' &&
@@ -2238,7 +2221,7 @@ function stepDates(dateField: string): string {
2238
2221
  // handled separately as a frequency phrase.
2239
2222
  function dateOrdinals(ir: IR, opts: NormalizedOptions): string {
2240
2223
  // Reached only with a restricted date, which has segments.
2241
- return renderSegments(ir.analyses.segments.date!, getOrdinal, opts);
2224
+ return renderSegments(segmentsOf(ir, 'date'), getOrdinal, opts);
2242
2225
  }
2243
2226
 
2244
2227
  // Render the month field as names. There are few, named months, so a step
@@ -2254,7 +2237,7 @@ function monthName(ir: IR, opts: NormalizedOptions): string {
2254
2237
 
2255
2238
  // A restricted month has segments; open steps of interval 3+ enumerate their
2256
2239
  // fires here too.
2257
- return renderSegments(ir.analyses.segments.month!, function name(value) {
2240
+ return renderSegments(segmentsOf(ir, 'month'), function name(value) {
2258
2241
  return getMonth(value, opts);
2259
2242
  }, opts);
2260
2243
  }
@@ -2293,7 +2276,7 @@ function weekdayPhrase(ir: IR, recurring: boolean,
2293
2276
  // Reached only with a restricted weekday, which has segments. Weekday lists
2294
2277
  // display Monday-first (Sunday last) so a weekend reads naturally; the IR
2295
2278
  // stays canonical (Sunday=0) and ranges keep their form.
2296
- const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
2279
+ const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
2297
2280
  const hasRange = segments.some(function range(segment) {
2298
2281
  return segment.kind === 'range';
2299
2282
  });
@@ -2345,14 +2328,6 @@ function renderSegments(segments: Segment[],
2345
2328
  return joinList(pieces, opts);
2346
2329
  }
2347
2330
 
2348
- // Whether a canonical field value is an "open" step (`*/n` or `a/n`, not a
2349
- // bounded range or a list). Open steps read as a frequency rather than an
2350
- // enumeration.
2351
- function isOpenStep(field: string): boolean {
2352
- return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
2353
- field.indexOf(',') === -1;
2354
- }
2355
-
2356
2331
  // --- Years. ---
2357
2332
 
2358
2333
  // Append or fold the year field into a finished description. An