cronli5 0.1.2 → 0.1.5

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,6 +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 {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
6
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
5
7
  import type {Cronli5Options} from '../../types.js';
6
8
  import type {
7
9
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -20,6 +22,19 @@ interface Unit {
20
22
  singular: string;
21
23
  }
22
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
+
23
38
  const UNITS: Record<'second' | 'minute' | 'hour', Unit> = {
24
39
  hour: {every: 'jede', plural: 'Stunden', singular: 'Stunde'},
25
40
  minute: {every: 'jede', plural: 'Minuten', singular: 'Minute'},
@@ -49,6 +64,98 @@ function cleanStep(segment: StepSegment, cycle: number): boolean {
49
64
  cycle % segment.interval === 0;
50
65
  }
51
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
+
52
159
  type NameToken = string | number;
53
160
  type NameSegment =
54
161
  | {kind: 'single'; value: NameToken}
@@ -61,11 +168,6 @@ const weekdayNames = [
61
168
  'freitags', 'samstags'
62
169
  ];
63
170
 
64
- // Cron weekday tokens (part of cron syntax), mapped to indices.
65
- const weekdayTokens: {[token: string]: number} = {
66
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
67
- };
68
-
69
171
  function fieldSegments(ir: IR, field: Field): Segment[] {
70
172
  return ir.analyses.segments[field] as Segment[];
71
173
  }
@@ -94,14 +196,9 @@ function joinList(items: string[]): string {
94
196
  return items.slice(0, -1).join(', ') + ' und ' + items[items.length - 1];
95
197
  }
96
198
 
97
- // The adverbial name for a weekday token (cron name or number; 7 = Sunday).
199
+ // The adverbial name for a canonical weekday number (0 = Sunday).
98
200
  function weekdayName(token: NameToken): string {
99
- if (token === '7' || token === 7) {
100
- return weekdayNames[0];
101
- }
102
-
103
- return weekdayNames[token as number] ||
104
- weekdayNames[weekdayTokens[token as string]];
201
+ return weekdayNames[+token];
105
202
  }
106
203
 
107
204
  // "montags bis freitags".
@@ -150,12 +247,24 @@ function everyNthHour(segment: StepSegment): string {
150
247
  return start === 0 ? base : base + ' ab ' + start + ' Uhr';
151
248
  }
152
249
 
153
- function weekdayNoun(token: string): string {
154
- if (token === '7') {
155
- return weekdayNouns[0];
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;
156
257
  }
157
258
 
158
- return weekdayNouns[token in weekdayTokens ? weekdayTokens[token] : +token];
259
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
260
+
261
+ return 24 % segment.interval === 0 && start < segment.interval;
262
+ }
263
+
264
+ // The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
265
+ // core, so it may still be a name token; resolve it via the core's index.
266
+ function weekdayNoun(token: string): string {
267
+ return weekdayNouns[toFieldNumber(token, weekdayNumbers)];
159
268
  }
160
269
 
161
270
  // The Quartz weekday phrase: "am letzten Freitag des Monats", "am zweiten
@@ -193,18 +302,12 @@ function quartzDate(field: string): string | null {
193
302
  return null;
194
303
  }
195
304
 
196
- // Cron month tokens (part of cron syntax), mapped to indices. The month names
197
- // themselves are dialect-scoped and resolved from `opts.style.months`.
198
- const monthTokens: {[token: string]: number} = {
199
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
200
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
201
- };
202
-
203
305
  type Months = GermanStyle['months'];
204
306
 
307
+ // The month names are dialect-scoped (resolved from `opts.style.months`);
308
+ // the canonical month number indexes them.
205
309
  function monthName(token: NameToken, months: Months): string {
206
- return (months[token as number] ||
207
- months[monthTokens[token as string]]) as string;
310
+ return months[+token] as string;
208
311
  }
209
312
 
210
313
  // "von Juni bis August".
@@ -360,17 +463,30 @@ function countedPhrase(
360
463
  // The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
361
464
  // jeder Minute".
362
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 {
363
475
  if (ir.pattern.second === '*') {
364
476
  return 'jede Sekunde';
365
477
  }
366
478
 
367
479
  const segments = ir.analyses.segments.second;
368
480
 
369
- if (ir.shapes.second === 'step' && cleanStep(stepSegment(segments), 60)) {
370
- 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);
371
486
  }
372
487
 
373
- return countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' jeder Minute';
488
+ return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
489
+ countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
374
490
  }
375
491
 
376
492
  // A clock time that always shows its minutes: "9:00", "9:30".
@@ -492,9 +608,17 @@ function renderSeconds(ir: IR): string {
492
608
  }
493
609
 
494
610
  // The minute-past-the-hour clause: "in Minute 5 jeder Stunde", "in den
495
- // 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
+
496
620
  function renderMinutePast(ir: IR): string {
497
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
621
+ return minutePastClause(ir);
498
622
  }
499
623
 
500
624
  // A specific minute and second: "in Minute 0 und Sekunde 30 jeder Stunde".
@@ -511,12 +635,33 @@ function renderSecondsWithinMinute(
511
635
  ' jeder Stunde';
512
636
  }
513
637
 
514
- // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr".
638
+ // The whole-hour noun in the genitive: "der Mitternachtsstunde" (0), "der
639
+ // Mittagsstunde" (12), or "der <H>-Uhr-Stunde" for any other hour.
640
+ function wholeHour(hour: number): string {
641
+ if (hour === 0) {
642
+ return 'der Mitternachtsstunde';
643
+ }
644
+
645
+ if (hour === 12) {
646
+ return 'der Mittagsstunde';
647
+ }
648
+
649
+ return 'der ' + hour + '-Uhr-Stunde';
650
+ }
651
+
652
+ // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr". A
653
+ // wildcard minute is the whole hour, so it reads as that hour itself ("jede
654
+ // Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
655
+ // range the source never stated; a plain range is a real window and keeps it.
515
656
  function renderMinuteSpanInHour(
516
657
  ir: IR,
517
658
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
518
659
  opts: Opts
519
660
  ): string {
661
+ if (ir.pattern.minute === '*') {
662
+ return 'jede Minute ' + wholeHour(plan.hour);
663
+ }
664
+
520
665
  const sep = opts.style.sep;
521
666
 
522
667
  return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
@@ -530,9 +675,75 @@ function renderComposeSeconds(
530
675
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
531
676
  opts: Opts
532
677
  ): string {
678
+ // An hour step (or arithmetic-progression hour list) under a single pinned
679
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
680
+ // then the hour cadence ("in Sekunde 30 jeder Stunde, alle 2 Stunden"). The
681
+ // clock-time rest would otherwise cross-multiply the hours.
682
+ if ((plan.rest.kind === 'clockTimes' ||
683
+ plan.rest.kind === 'compactClockTimes') &&
684
+ ir.shapes.minute === 'single') {
685
+ const cadence = hourCadence(ir, +ir.pattern.minute);
686
+
687
+ if (cadence !== null) {
688
+ return cadence;
689
+ }
690
+ }
691
+
692
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
693
+ // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
694
+ // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
695
+ // Bind the seconds into the explicit clock minute in the genitive ("der
696
+ // Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
697
+ if (composeMinuteZero(ir, plan)) {
698
+ return secondsLead(ir) + ' ' +
699
+ clockMinuteGenitive(plan.rest.times, opts.style.sep);
700
+ }
701
+
702
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
703
+ // cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
704
+ // them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
705
+ // English. Other strides, a restricted hour, and an hour cadence keep the
706
+ // juxtaposed form.
707
+ if (plan.rest.kind === 'minuteFrequency' &&
708
+ ir.shapes.second === 'wildcard' && ir.shapes.hour === 'wildcard') {
709
+ const minuteStep = stepSegment(ir.analyses.segments.minute);
710
+
711
+ if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
712
+ return secondsLead(ir) + ' jeder zweiten Minute';
713
+ }
714
+ }
715
+
533
716
  return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
534
717
  }
535
718
 
719
+ // True when a compose-seconds plan is a sub-minute second over a minute-0
720
+ // clock-time rest — the case that reads as the bare hour and so must surface
721
+ // the pinned clock minute.
722
+ function composeMinuteZero(
723
+ ir: IR,
724
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
725
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
726
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
727
+ return plan.rest.kind === 'clockTimes' &&
728
+ plan.rest.times.every((time) => +time.minute === 0);
729
+ }
730
+
731
+ // The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
732
+ // "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
733
+ // minute-0 confinement stays visible.
734
+ function clockMinuteGenitive(
735
+ times: {hour: number; minute: number}[],
736
+ sep: string
737
+ ): string {
738
+ const clocks = times.map(function clock(time): string {
739
+ return time.hour + sep + pad(time.minute);
740
+ });
741
+
742
+ return clocks.length === 1 ?
743
+ 'der Minute ' + clocks[0] :
744
+ 'der Minuten ' + joinList(clocks);
745
+ }
746
+
536
747
  // A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
537
748
  // 17 Uhr".
538
749
  function renderMinutesAcrossHours(
@@ -551,13 +762,14 @@ function renderMinutesAcrossHours(
551
762
  atHours(plan.times.fires) :
552
763
  joinList(hourSegmentParts(ir, 0, 0, sep));
553
764
 
554
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
765
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
766
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
555
767
  }
556
768
 
557
769
  // A minute clause across a stepped hour range. A wildcard minute (a cadence)
558
770
  // is reached only for a clean step and is confined to every Nth hour ("jede
559
- // Minute in jeder zweiten Stunde"); a plain range is a per-hour window whose
560
- // recurrence trails ("in den Minuten 0 bis 30, alle 2 Stunden").
771
+ // Minute in jeder zweiten Stunde"); a range or list leads with its minutes and
772
+ // trails the same cadence ("in den Minuten 0 bis 30, in jeder zweiten Stunde").
561
773
  function renderMinuteSpanAcrossHourStep(
562
774
  ir: IR,
563
775
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
@@ -567,8 +779,17 @@ function renderMinuteSpanAcrossHourStep(
567
779
  everyNthHour(stepSegment(ir.analyses.segments.hour));
568
780
  }
569
781
 
570
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' +
571
- hourStepPhrase(ir);
782
+ // The minute (range or list) leads; the hour trails. A clean stride confines
783
+ // to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
784
+ // minute-step compositions use, never a juxtaposed second frequency. A
785
+ // bounded stride (reachable only via a range) enumerates its fires instead.
786
+ const segment = stepSegment(ir.analyses.segments.hour);
787
+ const hours = confinedHourStride(segment) ?
788
+ everyNthHour(segment) :
789
+ atHours(segment.fires);
790
+
791
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
792
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
572
793
  }
573
794
 
574
795
  // Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
@@ -584,6 +805,15 @@ function renderCompactClockTimes(
584
805
  const sep = opts.style.sep;
585
806
 
586
807
  if (plan.fold) {
808
+ // An hour step (or arithmetic-progression hour list) under the single
809
+ // pinned minute reads as a cadence, not a wall of clock times. (Returns
810
+ // null for an irregular list or a range, which keep folding below.)
811
+ const cadence = hourCadence(ir, plan.minute);
812
+
813
+ if (cadence !== null) {
814
+ return cadence;
815
+ }
816
+
587
817
  const hourly = fieldSegments(ir, 'hour')
588
818
  .some((segment) => segment.kind === 'range');
589
819
 
@@ -604,7 +834,8 @@ function renderCompactClockTimes(
604
834
  countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
605
835
 
606
836
  return lead +
607
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
837
+ (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
838
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
608
839
  }
609
840
 
610
841
  // A repeating minute step, optionally within an hour window: "alle 5
@@ -617,9 +848,7 @@ function renderMinuteFrequency(
617
848
  const segment = stepSegment(ir.analyses.segments.minute);
618
849
  const sep = opts.style.sep;
619
850
  const clean = cleanStep(segment, 60);
620
- const base = clean ?
621
- everyN(segment.interval, UNITS.minute) :
622
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
851
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
623
852
 
624
853
  if (plan.hours.kind === 'window') {
625
854
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
@@ -653,6 +882,147 @@ function hourStepPhrase(ir: IR): string {
653
882
  atHours(segment.fires);
654
883
  }
655
884
 
885
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
886
+
887
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
888
+ // from midnight is the bare cadence ("alle 2 Stunden"); a clean offset names
889
+ // only its start ("alle 6 Stunden ab 2 Uhr"); a bounded or non-tiling stride
890
+ // pins both clock-time endpoints ("alle 2 Stunden von 9 bis 17 Uhr") so the
891
+ // bounded set reads unambiguously. Used wherever an hour step (or
892
+ // arithmetic-progression hour list) would otherwise be cross-multiplied into a
893
+ // wall of clock times.
894
+ function hourStrideCadence(
895
+ stride: {start: number; interval: number; last: number}
896
+ ): string {
897
+ const {start, interval, last} = stride;
898
+ const cadence = everyN(interval, UNITS.hour);
899
+ const tiles = 24 % interval === 0;
900
+
901
+ if (start === 0 && tiles) {
902
+ return cadence;
903
+ }
904
+
905
+ if (start < interval && tiles) {
906
+ return cadence + ' ab ' + start + ' Uhr';
907
+ }
908
+
909
+ return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
910
+ }
911
+
912
+ // The hour field's stride, or null when the hour is not a cadence: a step
913
+ // segment yields its {start, interval, last} directly; an all-single hour list
914
+ // yields one only when its values form a long-enough arithmetic progression
915
+ // (so an irregular list like 9,17 keeps enumerating). The IR is unchanged —
916
+ // the renderer recognizes the stride and speaks it as a cadence instead of the
917
+ // clock-time cross-product.
918
+ function hourStride(
919
+ ir: IR
920
+ ): {start: number; interval: number; last: number} | null {
921
+ const segments = fieldSegments(ir, 'hour');
922
+
923
+ // A wildcard hour carries no segments (no discrete hours to stride over).
924
+ if (!segments) {
925
+ return null;
926
+ }
927
+
928
+ if (segments.length === 1 && segments[0].kind === 'step') {
929
+ const segment = segments[0];
930
+ const start = segment.startToken === '*' ?
931
+ 0 :
932
+ +segment.startToken.split('-')[0];
933
+
934
+ return {interval: segment.interval, last: segment.fires[
935
+ segment.fires.length - 1], start};
936
+ }
937
+
938
+ const values = singleValues(segments);
939
+ const step = values && arithmeticStep(values);
940
+
941
+ return step || null;
942
+ }
943
+
944
+ // The second's status against a pinned minute: a wildcard or sub-minute step
945
+ // fills the minute (a "für eine Minute" frame at minute 0); a single 0 is just
946
+ // the top of the minute (no clause); anything else needs its own clause.
947
+ function subMinuteSecond(ir: IR): boolean {
948
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
949
+ }
950
+
951
+ // The lead clause for an hour-cadence rendering: the second and the pinned
952
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
953
+ // list, or range second is counted "jeder Stunde" (the minute-0 is the top of
954
+ // the hour), and a wildcard or sub-minute step second takes a "für eine
955
+ // Minute" frame (the whole minute-0 window). A non-zero minute is a real clock
956
+ // minute: the second leads with its own clause (if any), then the minute reads
957
+ // "in Minute M".
958
+ function hourCadenceLead(ir: IR, minute: number): string {
959
+ if (minute === 0) {
960
+ if (subMinuteSecond(ir)) {
961
+ return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
962
+ }
963
+
964
+ return secondsClause(ir, 'jeder Stunde');
965
+ }
966
+
967
+ const minutePhrase = 'in Minute ' + minute;
968
+
969
+ // A single 0 second is just the top of the minute, so the minute leads
970
+ // alone; any other second prefixes its own clause.
971
+ if (ir.pattern.second === '0') {
972
+ return minutePhrase;
973
+ }
974
+
975
+ return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
976
+ }
977
+
978
+ // Render an hour step (or arithmetic-progression hour list) under a single
979
+ // pinned minute and a second as a cadence — the lead clause, then the hour
980
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
981
+ // Returns null when the hour is not a stride (an irregular list, a single
982
+ // hour, or a range), or when the cross-product is short enough that
983
+ // enumeration is no longer than the cadence: a meaningful second makes every
984
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
985
+ // the stride must exceed the clock-time cap, the same point at which the core
986
+ // itself stops enumerating. The renderer returns the bare clause; the day
987
+ // frame is composed in `describe`. Renderer-only; the IR is unchanged.
988
+ function hourCadence(ir: IR, minute: number): string | null {
989
+ const stride = hourStride(ir);
990
+
991
+ if (!stride) {
992
+ return null;
993
+ }
994
+
995
+ const fires = (stride.last - stride.start) / stride.interval + 1;
996
+
997
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
998
+ return null;
999
+ }
1000
+
1001
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1002
+ // stride is a confinement, not a juxtaposed cadence: it reads "für eine
1003
+ // Minute in jeder zweiten Stunde", reusing the every-Nth-hour idiom so the
1004
+ // minute-0 window is never heard as the bare hour cadence.
1005
+ const segment = fieldSegments(ir, 'hour')[0];
1006
+ const confined = minute === 0 && subMinuteSecond(ir) &&
1007
+ fieldSegments(ir, 'hour').length === 1 && segment.kind === 'step' &&
1008
+ confinedHourStride(segment);
1009
+
1010
+ if (confined) {
1011
+ return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
1012
+ everyNthHour(segment);
1013
+ }
1014
+
1015
+ return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
1016
+ }
1017
+
1018
+ // Whether an hour cadence applies to a plan with a single pinned minute — the
1019
+ // signal that the clause is a cadence, not a daily clock-time list, so the
1020
+ // "täglich" frame must not be added.
1021
+ function hourCadenceApplies(ir: IR): boolean {
1022
+ return ir.shapes.minute === 'single' &&
1023
+ hourCadence(ir, +ir.pattern.minute) !== null;
1024
+ }
1025
+
656
1026
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
657
1027
  function renderHourRange(
658
1028
  ir: IR,
@@ -684,6 +1054,16 @@ function renderClockTimes(
684
1054
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
685
1055
  opts: Opts
686
1056
  ): string {
1057
+ // An hour step (or arithmetic-progression hour list) under a single pinned
1058
+ // minute reads as a cadence rather than a cross-product of clock times.
1059
+ if (ir.shapes.minute === 'single') {
1060
+ const cadence = hourCadence(ir, +ir.pattern.minute);
1061
+
1062
+ if (cadence !== null) {
1063
+ return cadence;
1064
+ }
1065
+ }
1066
+
687
1067
  return 'um ' + timesPhrase(plan.times, opts.style.sep);
688
1068
  }
689
1069
 
@@ -741,15 +1121,38 @@ function qualifier(ir: IR, months: Months): string {
741
1121
  }
742
1122
 
743
1123
  // Plan kinds whose clause is a clock time: the qualifier leads them ("montags
744
- // um 9 Uhr"); a frequency clause trails it ("jede Minute montags").
1124
+ // um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
1125
+ // minute-0 compose-seconds clause is anchored on a clock minute too, so the
1126
+ // qualifier leads it ("montags jede Sekunde der Minute 9:00").
745
1127
  const LEADING_PLANS = new Set(['clockTimes']);
746
1128
 
1129
+ // True when the leading qualifier should precede the clause: a clock-time
1130
+ // plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
1131
+ function leadsQualifier(ir: IR): boolean {
1132
+ return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
1133
+ }
1134
+
1135
+ // Whether the planned clause is the minute-0 compose-seconds confinement
1136
+ // (a sub-minute second over a minute-0 clock-time rest).
1137
+ function isComposeMinuteZero(ir: IR): boolean {
1138
+ return ir.plan.kind === 'composeSeconds' &&
1139
+ composeMinuteZero(ir, ir.plan);
1140
+ }
1141
+
747
1142
  // True when the clause is a bare daily clock-time list and so needs the
748
- // "täglich" frame to read as recurring, not a one-off: clockTimes always, and
749
- // an uneven hour step (rendered as its fire list "um 0, 5, Uhr", not the
750
- // cadence "alle N Stunden"). A frequency clause already implies recurrence.
1143
+ // "täglich" frame to read as recurring, not a one-off: clockTimes always, the
1144
+ // minute-0 compose-seconds clause (a recurring clock minute), and an uneven
1145
+ // hour step (rendered as its fire list "um 0, 5, Uhr", not the cadence "alle
1146
+ // N Stunden"). A frequency clause already implies recurrence.
751
1147
  function needsDailyFrame(ir: IR): boolean {
752
- if (ir.plan.kind === 'clockTimes') {
1148
+ // An hour cadence is a sub-daily frequency, not a daily clock-time list, so
1149
+ // it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
1150
+ // 2 Stunden").
1151
+ if (hourCadenceApplies(ir)) {
1152
+ return false;
1153
+ }
1154
+
1155
+ if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
753
1156
  return true;
754
1157
  }
755
1158
 
@@ -804,7 +1207,7 @@ function describe(ir: IR, opts: Opts): string {
804
1207
  let base = core;
805
1208
 
806
1209
  if (qual) {
807
- base = LEADING_PLANS.has(ir.plan.kind) ?
1210
+ base = leadsQualifier(ir) ?
808
1211
  qual + ' ' + core :
809
1212
  core + ' ' + qual;
810
1213
  }