cronli5 0.1.4 → 0.1.6

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.
@@ -2,8 +2,8 @@
2
2
  // German. Anchored to Duden; see notes.md for the decisions.
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
- import {weekdayNumbers} from '../../core/specs.js';
6
- import {toFieldNumber} from '../../core/util.js';
5
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
6
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
7
7
  import type {Cronli5Options} from '../../types.js';
8
8
  import type {
9
9
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -22,6 +22,19 @@ interface Unit {
22
22
  singular: string;
23
23
  }
24
24
 
25
+ // A step cadence to phrase: the `interval` repeats over a `cycle`-long field
26
+ // (60 for minute/second), running from `start` to `last`. `anchor` is the
27
+ // scope clause ("jeder Stunde"); an empty anchor lets the caller supply its
28
+ // own trailing scope, dropping the tail.
29
+ interface Stride {
30
+ interval: number;
31
+ start: number;
32
+ last: number;
33
+ cycle: number;
34
+ unit: Unit;
35
+ anchor: string;
36
+ }
37
+
25
38
  const UNITS: Record<'second' | 'minute' | 'hour', Unit> = {
26
39
  hour: {every: 'jede', plural: 'Stunden', singular: 'Stunde'},
27
40
  minute: {every: 'jede', plural: 'Minuten', singular: 'Minute'},
@@ -51,6 +64,98 @@ function cleanStep(segment: StepSegment, cycle: number): boolean {
51
64
  cycle % segment.interval === 0;
52
65
  }
53
66
 
67
+ // Speak a step cadence over a `cycle`-long field (60 for minute/second). A
68
+ // clean stride from the top of the cycle is the bare cadence ("alle 15
69
+ // Minuten"); a uniform offset (start within the first interval, the interval
70
+ // still dividing the cycle) names only its start, since it wraps cleanly with
71
+ // no distinct endpoint ("alle 6 Minuten ab Minute 5 jeder Stunde"); a
72
+ // non-uniform stride (start >= interval, or an interval that does not divide
73
+ // the cycle) pins both endpoints so the bounded, non-wrapping set reads
74
+ // unambiguously ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde"). This is
75
+ // the one phrasing for every step the renderer speaks, whether the core kept
76
+ // it a step shape (a clean cadence) or enumerated it to a fire list (an
77
+ // offset/uneven set the list path recognizes as a progression).
78
+ function renderStride(stride: Stride): string {
79
+ const {interval, start, last, cycle, unit, anchor} = stride;
80
+ const cadence = everyN(interval, unit);
81
+ const tiles = cycle % interval === 0;
82
+
83
+ if (start === 0 && tiles) {
84
+ return cadence;
85
+ }
86
+
87
+ // A context that supplies its own trailing scope passes an empty anchor, so
88
+ // the cadence keeps its endpoints but drops the "jeder Stunde" tail.
89
+ const tail = anchor ? ' ' + anchor : '';
90
+
91
+ if (start < interval && tiles) {
92
+ return cadence + ' ab ' + unit.singular + ' ' + start + tail;
93
+ }
94
+
95
+ return cadence + ' von ' + unit.singular + ' ' + start + ' bis ' + last +
96
+ tail;
97
+ }
98
+
99
+ // A step *shape* segment as its cadence ("alle 6 Minuten ab Minute 5 jeder
100
+ // Stunde"). A bounded sub-range step (`a-b/n`) is not a whole-cycle stride, so
101
+ // it lists its fires; a short offset cadence (three fires or fewer) also lists,
102
+ // since the list is no longer than the cadence. Everything else routes through
103
+ // `renderStride`. The uneven whole-cycle step (interval not dividing the cycle)
104
+ // never reaches here as a step shape — the core enumerates it to a fire list,
105
+ // which the list path recognizes instead.
106
+ function stepClause(segment: StepSegment, unit: Unit, anchor: string): string {
107
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
108
+ const short = start !== 0 && segment.fires.length <= 3;
109
+
110
+ if (segment.startToken.indexOf('-') !== -1 || short) {
111
+ return 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)) +
112
+ ' ' + anchor;
113
+ }
114
+
115
+ return renderStride({
116
+ interval: segment.interval,
117
+ start,
118
+ last: segment.fires[segment.fires.length - 1],
119
+ cycle: 60,
120
+ unit,
121
+ anchor
122
+ });
123
+ }
124
+
125
+ // The sorted numeric values a field's segments cover, or null if any segment
126
+ // is not a discrete single (a range or sub-step is not a plain fire list).
127
+ function singleValues(segments: Segment[]): number[] | null {
128
+ const values: number[] = [];
129
+
130
+ for (const segment of segments) {
131
+ if (segment.kind !== 'single') {
132
+ return null;
133
+ }
134
+
135
+ values.push(+segment.value);
136
+ }
137
+
138
+ return values;
139
+ }
140
+
141
+ // Speak a minute/second field's enumerated fires as a step cadence when they
142
+ // form an arithmetic progression long enough to beat the list (the core
143
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
144
+ // the renderer recognizes the progression). Returns null for a non-progression
145
+ // or a too-short list, leaving the caller to enumerate.
146
+ function strideFromSegments(
147
+ segments: Segment[],
148
+ unit: Unit,
149
+ anchor: string
150
+ ): string | null {
151
+ const values = singleValues(segments);
152
+ const step = values && arithmeticStep(values);
153
+
154
+ return step ?
155
+ renderStride({...step, cycle: 60, unit, anchor}) :
156
+ null;
157
+ }
158
+
54
159
  type NameToken = string | number;
55
160
  type NameSegment =
56
161
  | {kind: 'single'; value: NameToken}
@@ -142,6 +247,20 @@ function everyNthHour(segment: StepSegment): string {
142
247
  return start === 0 ? base : base + ' ab ' + start + ' Uhr';
143
248
  }
144
249
 
250
+ // Whether an hour step is a clean stride over the whole day — unbounded,
251
+ // dividing 24, and starting within the first interval — so it confines to "in
252
+ // jeder N-ten Stunde" rather than enumerating its fires. Mirrors the core's
253
+ // cleanHourStride: an offset like 1/2 qualifies; bounded (9-17/2) does not.
254
+ function confinedHourStride(segment: StepSegment): boolean {
255
+ if (segment.startToken.indexOf('-') !== -1) {
256
+ return false;
257
+ }
258
+
259
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
260
+
261
+ return 24 % segment.interval === 0 && start < segment.interval;
262
+ }
263
+
145
264
  // The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
146
265
  // core, so it may still be a name token; resolve it via the core's index.
147
266
  function weekdayNoun(token: string): string {
@@ -344,17 +463,30 @@ function countedPhrase(
344
463
  // The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
345
464
  // jeder Minute".
346
465
  function secondsLead(ir: IR): string {
466
+ return secondsClause(ir, 'jeder Minute');
467
+ }
468
+
469
+ // The second clause counted against an arbitrary anchor. The anchor is "jeder
470
+ // Minute" in the standalone seconds path; the hour-cadence path folds a pinned
471
+ // minute 0 into the hour and counts the second "jeder Stunde" instead ("in
472
+ // Sekunde 30 jeder Stunde"), so the minute-0 confinement is stated, not
473
+ // dropped.
474
+ function secondsClause(ir: IR, anchor: string): string {
347
475
  if (ir.pattern.second === '*') {
348
476
  return 'jede Sekunde';
349
477
  }
350
478
 
351
479
  const segments = ir.analyses.segments.second;
352
480
 
353
- if (ir.shapes.second === 'step' && cleanStep(stepSegment(segments), 60)) {
354
- return everyN(stepSegment(segments).interval, UNITS.second);
481
+ // A step shape speaks its cadence directly; an offset/uneven step the core
482
+ // enumerated to a list is recognized as a progression. Both fall back to the
483
+ // counted list (a short or irregular set).
484
+ if (ir.shapes.second === 'step') {
485
+ return stepClause(stepSegment(segments), UNITS.second, anchor);
355
486
  }
356
487
 
357
- return countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' jeder Minute';
488
+ return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
489
+ countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
358
490
  }
359
491
 
360
492
  // A clock time that always shows its minutes: "9:00", "9:30".
@@ -476,9 +608,17 @@ function renderSeconds(ir: IR): string {
476
608
  }
477
609
 
478
610
  // The minute-past-the-hour clause: "in Minute 5 jeder Stunde", "in den
479
- // Minuten 5, 10 und 30 jeder Stunde".
611
+ // Minuten 5, 10 und 30 jeder Stunde". An offset/uneven step the core
612
+ // enumerated to this list reads as a stride cadence when the fires form a
613
+ // long-enough progression ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde").
614
+ function minutePastClause(ir: IR): string {
615
+ return strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
616
+ 'jeder Stunde') ??
617
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
618
+ }
619
+
480
620
  function renderMinutePast(ir: IR): string {
481
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
621
+ return minutePastClause(ir);
482
622
  }
483
623
 
484
624
  // A specific minute and second: "in Minute 0 und Sekunde 30 jeder Stunde".
@@ -530,11 +670,45 @@ function renderMinuteSpanInHour(
530
670
 
531
671
  // Seconds composed with the rest: "in den Sekunden 0 und 30 jeder Minute, um
532
672
  // 9:05 Uhr".
673
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
674
+ // cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
675
+ // them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
676
+ // English. Other strides, a restricted hour, and an hour cadence keep the
677
+ // juxtaposed form.
678
+ function isEveryOtherMinuteSeconds(
679
+ ir: IR,
680
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
681
+ ): boolean {
682
+ if (plan.rest.kind !== 'minuteFrequency' ||
683
+ ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
684
+ return false;
685
+ }
686
+
687
+ const minuteStep = stepSegment(ir.analyses.segments.minute);
688
+
689
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
690
+ }
691
+
533
692
  function renderComposeSeconds(
534
693
  ir: IR,
535
694
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
536
695
  opts: Opts
537
696
  ): string {
697
+ // An hour step (or arithmetic-progression hour list) under a single pinned
698
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
699
+ // then the hour cadence ("in Sekunde 30 jeder Stunde, alle 2 Stunden"). The
700
+ // clock-time rest would otherwise cross-multiply the hours.
701
+ if ((plan.rest.kind === 'clockTimes' ||
702
+ plan.rest.kind === 'compactClockTimes') &&
703
+ ir.shapes.minute === 'single') {
704
+ const minute = +ir.pattern.minute;
705
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
706
+
707
+ if (cadence !== null) {
708
+ return cadence;
709
+ }
710
+ }
711
+
538
712
  // A sub-minute second with the minute pinned to 0 and a specific hour: the
539
713
  // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
540
714
  // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
@@ -545,7 +719,21 @@ function renderComposeSeconds(
545
719
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
546
720
  }
547
721
 
548
- return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
722
+ // A wildcard second under a minute */2 with a wildcard hour binds in the
723
+ // genitive ("jede Sekunde jeder zweiten Minute").
724
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
725
+ return secondsLead(ir) + ' jeder zweiten Minute';
726
+ }
727
+
728
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
729
+ // leading clause, so the composer must not prepend a second lead that would
730
+ // double it. A wildcard or stepped second is not folded there (no
731
+ // clockSecond), so it still leads its own clause here.
732
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
733
+ ir.analyses.clockSecond;
734
+ const lead = restOwnsLead ? '' : secondsLead(ir) + ', ';
735
+
736
+ return lead + render(ir, plan.rest, opts);
549
737
  }
550
738
 
551
739
  // True when a compose-seconds plan is a sub-minute second over a minute-0
@@ -584,34 +772,62 @@ function renderMinutesAcrossHours(
584
772
  opts: Opts
585
773
  ): string {
586
774
  const sep = opts.style.sep;
775
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence,
776
+ // not a wall of hour columns.
777
+ const cadence = unevenHourCadence(ir);
587
778
 
588
779
  // The wildcard form means every minute *during* each hour: render windows.
589
780
  if (plan.form === 'wildcard') {
590
- return 'jede Minute ' + duringHours(ir, plan.times, sep);
781
+ return cadence ?
782
+ 'jede Minute, ' + cadence :
783
+ 'jede Minute ' + duringHours(ir, plan.times, sep);
784
+ }
785
+
786
+ const minuteLead =
787
+ strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
788
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten');
789
+
790
+ if (cadence !== null) {
791
+ return minuteLead + ', ' + cadence;
591
792
  }
592
793
 
593
794
  const hours = plan.times.kind === 'fires' ?
594
795
  atHours(plan.times.fires) :
595
796
  joinList(hourSegmentParts(ir, 0, 0, sep));
596
797
 
597
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
798
+ return minuteLead + ', ' + hours;
598
799
  }
599
800
 
600
801
  // A minute clause across a stepped hour range. A wildcard minute (a cadence)
601
802
  // is reached only for a clean step and is confined to every Nth hour ("jede
602
- // Minute in jeder zweiten Stunde"); a plain range is a per-hour window whose
603
- // recurrence trails ("in den Minuten 0 bis 30, alle 2 Stunden").
803
+ // Minute in jeder zweiten Stunde"); a range or list leads with its minutes and
804
+ // trails the same cadence ("in den Minuten 0 bis 30, in jeder zweiten Stunde").
604
805
  function renderMinuteSpanAcrossHourStep(
605
806
  ir: IR,
606
807
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
607
808
  ): string {
809
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
810
+ // offset-clean stride keeps its "in jeder N-ten Stunde" confinement.
811
+ const cadence = unevenHourCadence(ir);
812
+
813
+ // A wildcard minute over a stepped hour is reached only for a clean stride (a
814
+ // bounded or uneven step routes through minutesAcrossHours instead).
608
815
  if (plan.form === 'wildcard') {
609
816
  return 'jede Minute ' +
610
817
  everyNthHour(stepSegment(ir.analyses.segments.hour));
611
818
  }
612
819
 
613
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' +
614
- hourStepPhrase(ir);
820
+ // The minute (range or list) leads; the hour trails. A clean stride confines
821
+ // to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
822
+ // minute-step compositions use, never a juxtaposed second frequency. A
823
+ // bounded or uneven stride trails its endpoint-pinning cadence instead.
824
+ const segment = stepSegment(ir.analyses.segments.hour);
825
+ const hours = cadence ?? (confinedHourStride(segment) ?
826
+ everyNthHour(segment) :
827
+ atHours(segment.fires));
828
+
829
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
830
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
615
831
  }
616
832
 
617
833
  // Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
@@ -627,6 +843,16 @@ function renderCompactClockTimes(
627
843
  const sep = opts.style.sep;
628
844
 
629
845
  if (plan.fold) {
846
+ // An hour step or range (or arithmetic-progression hour list) under the
847
+ // single pinned minute reads as a cadence or window, not a wall of clock
848
+ // times. (Returns null for an irregular list, which keeps folding below.)
849
+ const cadence = hourCadence(ir, plan.minute) ??
850
+ hourRangeCadence(ir, plan.minute);
851
+
852
+ if (cadence !== null) {
853
+ return cadence;
854
+ }
855
+
630
856
  const hourly = fieldSegments(ir, 'hour')
631
857
  .some((segment) => segment.kind === 'range');
632
858
 
@@ -634,11 +860,12 @@ function renderCompactClockTimes(
634
860
  joinList(hourSegmentParts(ir, plan.minute, ir.analyses.clockSecond, sep));
635
861
  }
636
862
 
637
- // A range among the hours reads as a window; otherwise a flat hour list.
638
- const hours = fieldSegments(ir, 'hour')
639
- .some((segment) => segment.kind === 'range') ?
640
- joinList(hourSegmentParts(ir, 0, 0, sep)) :
641
- atHours(hourFires(ir));
863
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; else
864
+ // a range among the hours reads as a window, otherwise a flat hour list.
865
+ const hours = unevenHourCadence(ir) ??
866
+ (fieldSegments(ir, 'hour').some((segment) => segment.kind === 'range') ?
867
+ joinList(hourSegmentParts(ir, 0, 0, sep)) :
868
+ atHours(hourFires(ir)));
642
869
 
643
870
  // A folded second has no single clock time to attach to here, so it leads
644
871
  // as its own clause ("in Sekunde 30, ..."). It is the bare second (not
@@ -647,7 +874,8 @@ function renderCompactClockTimes(
647
874
  countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
648
875
 
649
876
  return lead +
650
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
877
+ (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
878
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
651
879
  }
652
880
 
653
881
  // A repeating minute step, optionally within an hour window: "alle 5
@@ -660,9 +888,7 @@ function renderMinuteFrequency(
660
888
  const segment = stepSegment(ir.analyses.segments.minute);
661
889
  const sep = opts.style.sep;
662
890
  const clean = cleanStep(segment, 60);
663
- const base = clean ?
664
- everyN(segment.interval, UNITS.minute) :
665
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
891
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
666
892
 
667
893
  if (plan.hours.kind === 'window') {
668
894
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
@@ -672,7 +898,14 @@ function renderMinuteFrequency(
672
898
  }
673
899
 
674
900
  if (plan.hours.kind === 'during') {
675
- return base + ' ' + duringHours(ir, plan.hours.times, sep);
901
+ // A bounded or uneven hour stride confines the minute cadence to its own
902
+ // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
903
+ // 20 Uhr").
904
+ const cadence = unevenHourCadence(ir);
905
+
906
+ return cadence ?
907
+ base + ', ' + cadence :
908
+ base + ' ' + duringHours(ir, plan.hours.times, sep);
676
909
  }
677
910
 
678
911
  if (plan.hours.kind === 'step') {
@@ -685,10 +918,16 @@ function renderMinuteFrequency(
685
918
  return base;
686
919
  }
687
920
 
688
- // A stepped hour field as a phrase: the cadence when clean ("alle 6 Stunden"),
689
- // else its discrete fires when uneven or bounded ("um 0, 5, 10, 15 und 20
690
- // Uhr"). Shared by the bare hour step and the minute-step compositions.
921
+ // A stepped hour field as a phrase: an offset-clean stride is its bare or "ab"
922
+ // cadence; a bounded or uneven stride pins both ends ("alle 2 Stunden von 9
923
+ // bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
691
924
  function hourStepPhrase(ir: IR): string {
925
+ const cadence = unevenHourCadence(ir);
926
+
927
+ if (cadence !== null) {
928
+ return cadence;
929
+ }
930
+
692
931
  const segment = stepSegment(ir.analyses.segments.hour);
693
932
 
694
933
  return cleanStep(segment, 24) ?
@@ -696,6 +935,273 @@ function hourStepPhrase(ir: IR): string {
696
935
  atHours(segment.fires);
697
936
  }
698
937
 
938
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
939
+
940
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
941
+ // from midnight is the bare cadence ("alle 2 Stunden"); a clean offset names
942
+ // only its start ("alle 6 Stunden ab 2 Uhr"); a bounded or non-tiling stride
943
+ // pins both clock-time endpoints ("alle 2 Stunden von 9 bis 17 Uhr") so the
944
+ // bounded set reads unambiguously. Used wherever an hour step (or
945
+ // arithmetic-progression hour list) would otherwise be cross-multiplied into a
946
+ // wall of clock times.
947
+ function hourStrideCadence(
948
+ stride: {start: number; interval: number; last: number}
949
+ ): string {
950
+ const {start, interval, last} = stride;
951
+ const cadence = everyN(interval, UNITS.hour);
952
+ const tiles = 24 % interval === 0;
953
+
954
+ if (start === 0 && tiles) {
955
+ return cadence;
956
+ }
957
+
958
+ if (start < interval && tiles) {
959
+ return cadence + ' ab ' + start + ' Uhr';
960
+ }
961
+
962
+ return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
963
+ }
964
+
965
+ // An hour list's arithmetic progression, or null when its values are not a step
966
+ // the renderer should speak as a cadence. The core rewrites a uneven hour step
967
+ // (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
968
+ // fire list, indistinguishable in the IR from a hand-written list; the renderer
969
+ // recovers the cadence from the values. A progression starting at zero is a
970
+ // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
971
+ // a step when it is too long to be a deliberate clock-time list (9,17 is two
972
+ // named times, not a cadence). Interval one is a plain range, never a step.
973
+ function hourListStride(
974
+ values: number[]
975
+ ): {start: number; interval: number; last: number} | null {
976
+ if (values.length < 2) {
977
+ return null;
978
+ }
979
+
980
+ const interval = values[1] - values[0];
981
+
982
+ if (interval < 2) {
983
+ return null;
984
+ }
985
+
986
+ for (let i = 2; i < values.length; i += 1) {
987
+ if (values[i] - values[i - 1] !== interval) {
988
+ return null;
989
+ }
990
+ }
991
+
992
+ if (values[0] !== 0 && values.length < 5) {
993
+ return null;
994
+ }
995
+
996
+ return {interval, last: values[values.length - 1], start: values[0]};
997
+ }
998
+
999
+ // Whether an hour stride wraps the day cleanly from within its first interval
1000
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1001
+ // stride has no distinct endpoint and keeps its bare or "ab" cadence. Every
1002
+ // other stride — a uneven interval, or one starting at or past its interval (a
1003
+ // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1004
+ function offsetCleanStride(
1005
+ stride: {start: number; interval: number}
1006
+ ): boolean {
1007
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1008
+ }
1009
+
1010
+ // The hour field's stride, or null when the hour is not a cadence: a step
1011
+ // segment yields its {start, interval, last} directly; an all-single hour list
1012
+ // yields one only when its values form a step progression (so an irregular list
1013
+ // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1014
+ // the stride and speaks it as a cadence, not the clock-time cross-product.
1015
+ function hourStride(
1016
+ ir: IR
1017
+ ): {start: number; interval: number; last: number} | null {
1018
+ const segments = fieldSegments(ir, 'hour');
1019
+
1020
+ // A wildcard hour carries no segments (no discrete hours to stride over).
1021
+ if (!segments) {
1022
+ return null;
1023
+ }
1024
+
1025
+ if (segments.length === 1 && segments[0].kind === 'step') {
1026
+ const segment = segments[0];
1027
+
1028
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1029
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1030
+ if (segment.fires.length < 2) {
1031
+ return null;
1032
+ }
1033
+
1034
+ const start = segment.startToken === '*' ?
1035
+ 0 :
1036
+ +segment.startToken.split('-')[0];
1037
+
1038
+ return {interval: segment.interval, last: segment.fires[
1039
+ segment.fires.length - 1], start};
1040
+ }
1041
+
1042
+ const values = singleValues(segments);
1043
+
1044
+ return values && hourListStride(values);
1045
+ }
1046
+
1047
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1048
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1049
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1050
+ // enumerating list paths; there the bounded hour reads better as its cadence
1051
+ // ("…, alle 5 Stunden von 0 bis 20 Uhr") than as a wall of clock times. An
1052
+ // offset-clean stride keeps its existing confinement form, so only the
1053
+ // endpoint-bearing case routes here.
1054
+ function unevenHourCadence(ir: IR): string | null {
1055
+ const stride = hourStride(ir);
1056
+
1057
+ if (!stride || offsetCleanStride(stride)) {
1058
+ return null;
1059
+ }
1060
+
1061
+ return hourStrideCadence(stride);
1062
+ }
1063
+
1064
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1065
+ // fills the minute (a "für eine Minute" frame at minute 0); a single 0 is just
1066
+ // the top of the minute (no clause); anything else needs its own clause.
1067
+ function subMinuteSecond(ir: IR): boolean {
1068
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
1069
+ }
1070
+
1071
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1072
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
1073
+ // list, or range second is counted "jeder Stunde" (the minute-0 is the top of
1074
+ // the hour), and a wildcard or sub-minute step second takes a "für eine
1075
+ // Minute" frame (the whole minute-0 window). A non-zero minute is a real clock
1076
+ // minute: the second leads with its own clause (if any), then the minute reads
1077
+ // "in Minute M".
1078
+ function hourCadenceLead(ir: IR, minute: number): string {
1079
+ if (minute === 0) {
1080
+ if (subMinuteSecond(ir)) {
1081
+ return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
1082
+ }
1083
+
1084
+ return secondsClause(ir, 'jeder Stunde');
1085
+ }
1086
+
1087
+ const minutePhrase = 'in Minute ' + minute;
1088
+
1089
+ // A single 0 second is just the top of the minute, so the minute leads
1090
+ // alone; any other second prefixes its own clause.
1091
+ if (ir.pattern.second === '0') {
1092
+ return minutePhrase;
1093
+ }
1094
+
1095
+ return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
1096
+ }
1097
+
1098
+ // Render an hour step (or arithmetic-progression hour list) under a single
1099
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1100
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1101
+ // Returns null when the hour is not a stride (an irregular list, a single
1102
+ // hour, or a range), or when the cross-product is short enough that
1103
+ // enumeration is no longer than the cadence: a meaningful second makes every
1104
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
1105
+ // the stride must exceed the clock-time cap, the same point at which the core
1106
+ // itself stops enumerating. The renderer returns the bare clause; the day
1107
+ // frame is composed in `describe`. Renderer-only; the IR is unchanged.
1108
+ function hourCadence(ir: IR, minute: number): string | null {
1109
+ const stride = hourStride(ir);
1110
+
1111
+ if (!stride) {
1112
+ return null;
1113
+ }
1114
+
1115
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1116
+
1117
+ // A short stride that spells out as few clock times stays an enumeration only
1118
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1119
+ // or "ab" form is no shorter than the list. A bounded or uneven stride has no
1120
+ // clean wrap, so its endpoint-pinning cadence ("alle 5 Stunden von 0 bis 20
1121
+ // Uhr") reads better however short.
1122
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1123
+ offsetCleanStride(stride)) {
1124
+ return null;
1125
+ }
1126
+
1127
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1128
+ // stride is a confinement, not a juxtaposed cadence: it reads "für eine
1129
+ // Minute in jeder zweiten Stunde", reusing the every-Nth-hour idiom so the
1130
+ // minute-0 window is never heard as the bare hour cadence.
1131
+ const segment = fieldSegments(ir, 'hour')[0];
1132
+ const confined = minute === 0 && subMinuteSecond(ir) &&
1133
+ fieldSegments(ir, 'hour').length === 1 && segment.kind === 'step' &&
1134
+ confinedHourStride(segment);
1135
+
1136
+ if (confined) {
1137
+ return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
1138
+ everyNthHour(segment);
1139
+ }
1140
+
1141
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1142
+ // lead clause to fold in, so the bounded cadence stands on its own ("alle 5
1143
+ // Stunden von 0 bis 20 Uhr").
1144
+ if (minute === 0 && ir.pattern.second === '0') {
1145
+ return hourStrideCadence(stride);
1146
+ }
1147
+
1148
+ return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
1149
+ }
1150
+
1151
+ // Whether an hour cadence or hour-range window applies to a plan with a single
1152
+ // pinned minute — the signal that the clause is a cadence/window, not a daily
1153
+ // clock-time list, so the "täglich" frame must not be added.
1154
+ function hourCadenceApplies(ir: IR): boolean {
1155
+ if (ir.shapes.minute !== 'single') {
1156
+ return false;
1157
+ }
1158
+
1159
+ const minute = +ir.pattern.minute;
1160
+
1161
+ return hourCadence(ir, minute) !== null ||
1162
+ hourRangeCadence(ir, minute) !== null;
1163
+ }
1164
+
1165
+ // Whether the hour field is a range — or a list whose segments include a
1166
+ // range — and so forms a window rather than a cross-product of clock times.
1167
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1168
+ // a step is handled by hourStride/hourCadence.
1169
+ function hasHourWindow(ir: IR): boolean {
1170
+ const segments = fieldSegments(ir, 'hour');
1171
+
1172
+ return !!segments && segments.some(function range(segment) {
1173
+ return segment.kind === 'range';
1174
+ });
1175
+ }
1176
+
1177
+ // Render an hour range (or a list whose segments include a range) under
1178
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1179
+ // then "von 9 bis 17 Uhr" (and any non-contiguous hour as "und um 22 Uhr") —
1180
+ // instead of cross-multiplying the hours into a wall of clock times. The
1181
+ // hour-RANGE analog of hourCadence; returns the bare clause (the day frame is
1182
+ // suppressed by hourCadenceApplies). Returns null when the hour has no range,
1183
+ // when the minute is non-zero (a real clock minute the existing window form
1184
+ // already speaks), or when a plain :00 set carries no clause. Renderer-only;
1185
+ // the IR is unchanged.
1186
+ function hourRangeCadence(ir: IR, minute: number): string | null {
1187
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1188
+ return null;
1189
+ }
1190
+
1191
+ return hourCadenceLead(ir, minute) + ', ' + hourRangeWindowTail(ir);
1192
+ }
1193
+
1194
+ // The hour-range window as a cadence tail at the top of each hour: each range
1195
+ // segment is "von X bis Y Uhr", any non-contiguous hour is "um Z Uhr", joined
1196
+ // — the same parts the bare "stündlich von 9 bis 17 Uhr" window forms, minus
1197
+ // the "stündlich" prefix the lead replaces. The minute has folded into the
1198
+ // lead, so the parts close on the top of their final hour.
1199
+ function hourRangeWindowTail(ir: IR): string {
1200
+ // Minute 0 with a falsy second renders each part as a bare hour ("von 9 bis
1201
+ // 17 Uhr", "um 22 Uhr"); the separator is unused in that path.
1202
+ return joinList(hourSegmentParts(ir, 0, 0, ':'));
1203
+ }
1204
+
699
1205
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
700
1206
  function renderHourRange(
701
1207
  ir: IR,
@@ -716,9 +1222,15 @@ function renderHourRange(
716
1222
  return 'stündlich ' + window;
717
1223
  }
718
1224
 
719
- // A non-zero single minute ('lead') or a minute range leads the window.
720
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') +
721
- ' jeder Stunde, ' + window;
1225
+ // A non-zero single minute ('lead') or a minute range leads the window. A
1226
+ // non-uniform minute step the core enumerated to a fire list reads as its
1227
+ // bounded cadence ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde") instead
1228
+ // of the wall of fires; an irregular list or a single minute keeps the
1229
+ // counted form.
1230
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
1231
+ 'jeder Stunde') ??
1232
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
1233
+ ', ' + window;
722
1234
  }
723
1235
 
724
1236
  // One or more clock times: "um 9 Uhr", "um 14:30 Uhr", "um 9 und 17 Uhr".
@@ -727,6 +1239,18 @@ function renderClockTimes(
727
1239
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
728
1240
  opts: Opts
729
1241
  ): string {
1242
+ // An hour step or range (or arithmetic-progression hour list) under a single
1243
+ // pinned minute reads as a cadence or window rather than a cross-product of
1244
+ // clock times.
1245
+ if (ir.shapes.minute === 'single') {
1246
+ const minute = +ir.pattern.minute;
1247
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
1248
+
1249
+ if (cadence !== null) {
1250
+ return cadence;
1251
+ }
1252
+ }
1253
+
730
1254
  return 'um ' + timesPhrase(plan.times, opts.style.sep);
731
1255
  }
732
1256
 
@@ -808,6 +1332,13 @@ function isComposeMinuteZero(ir: IR): boolean {
808
1332
  // hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
809
1333
  // N Stunden"). A frequency clause already implies recurrence.
810
1334
  function needsDailyFrame(ir: IR): boolean {
1335
+ // An hour cadence is a sub-daily frequency, not a daily clock-time list, so
1336
+ // it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
1337
+ // 2 Stunden").
1338
+ if (hourCadenceApplies(ir)) {
1339
+ return false;
1340
+ }
1341
+
811
1342
  if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
812
1343
  return true;
813
1344
  }