cronli5 0.1.4 → 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,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".
@@ -535,6 +675,20 @@ function renderComposeSeconds(
535
675
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
536
676
  opts: Opts
537
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
+
538
692
  // A sub-minute second with the minute pinned to 0 and a specific hour: the
539
693
  // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
540
694
  // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
@@ -545,6 +699,20 @@ function renderComposeSeconds(
545
699
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
546
700
  }
547
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
+
548
716
  return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
549
717
  }
550
718
 
@@ -594,13 +762,14 @@ function renderMinutesAcrossHours(
594
762
  atHours(plan.times.fires) :
595
763
  joinList(hourSegmentParts(ir, 0, 0, sep));
596
764
 
597
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
765
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
766
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
598
767
  }
599
768
 
600
769
  // A minute clause across a stepped hour range. A wildcard minute (a cadence)
601
770
  // 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").
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").
604
773
  function renderMinuteSpanAcrossHourStep(
605
774
  ir: IR,
606
775
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
@@ -610,8 +779,17 @@ function renderMinuteSpanAcrossHourStep(
610
779
  everyNthHour(stepSegment(ir.analyses.segments.hour));
611
780
  }
612
781
 
613
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' +
614
- 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;
615
793
  }
616
794
 
617
795
  // Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
@@ -627,6 +805,15 @@ function renderCompactClockTimes(
627
805
  const sep = opts.style.sep;
628
806
 
629
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
+
630
817
  const hourly = fieldSegments(ir, 'hour')
631
818
  .some((segment) => segment.kind === 'range');
632
819
 
@@ -647,7 +834,8 @@ function renderCompactClockTimes(
647
834
  countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
648
835
 
649
836
  return lead +
650
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ', ' + hours;
837
+ (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
838
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
651
839
  }
652
840
 
653
841
  // A repeating minute step, optionally within an hour window: "alle 5
@@ -660,9 +848,7 @@ function renderMinuteFrequency(
660
848
  const segment = stepSegment(ir.analyses.segments.minute);
661
849
  const sep = opts.style.sep;
662
850
  const clean = cleanStep(segment, 60);
663
- const base = clean ?
664
- everyN(segment.interval, UNITS.minute) :
665
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
851
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
666
852
 
667
853
  if (plan.hours.kind === 'window') {
668
854
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
@@ -696,6 +882,147 @@ function hourStepPhrase(ir: IR): string {
696
882
  atHours(segment.fires);
697
883
  }
698
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
+
699
1026
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
700
1027
  function renderHourRange(
701
1028
  ir: IR,
@@ -727,6 +1054,16 @@ function renderClockTimes(
727
1054
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
728
1055
  opts: Opts
729
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
+
730
1067
  return 'um ' + timesPhrase(plan.times, opts.style.sep);
731
1068
  }
732
1069
 
@@ -808,6 +1145,13 @@ function isComposeMinuteZero(ir: IR): boolean {
808
1145
  // hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
809
1146
  // N Stunden"). A frequency clause already implies recurrence.
810
1147
  function needsDailyFrame(ir: IR): boolean {
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
+
811
1155
  if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
812
1156
  return true;
813
1157
  }