cronli5 0.1.5 → 0.1.7

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,9 @@
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
5
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
6
- import {arithmeticStep, toFieldNumber} from '../../core/util.js';
6
+ import {
7
+ arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
8
+ } from '../../core/util.js';
7
9
  import type {Cronli5Options} from '../../types.js';
8
10
  import type {
9
11
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -51,6 +53,12 @@ function everyN(interval: number, unit: Unit): string {
51
53
  return 'alle ' + interval + ' ' + unit.plural;
52
54
  }
53
55
 
56
+ // Append a scope anchor to a clause, separated by a space; an empty anchor
57
+ // (a context that names that field in its own clause) leaves the clause bare.
58
+ function withAnchor(clause: string, anchor: string): string {
59
+ return anchor ? clause + ' ' + anchor : clause;
60
+ }
61
+
54
62
  // The first segment of a step field, which the plan guarantees is step-kinded.
55
63
  function stepSegment(segments: Segment[] | null): StepSegment {
56
64
  return (segments as Segment[])[0] as StepSegment;
@@ -108,8 +116,9 @@ function stepClause(segment: StepSegment, unit: Unit, anchor: string): string {
108
116
  const short = start !== 0 && segment.fires.length <= 3;
109
117
 
110
118
  if (segment.startToken.indexOf('-') !== -1 || short) {
111
- return 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)) +
112
- ' ' + anchor;
119
+ return withAnchor(
120
+ 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)),
121
+ anchor);
113
122
  }
114
123
 
115
124
  return renderStride({
@@ -208,7 +217,9 @@ function weekdayRange(bounds: [string, string]): string {
208
217
 
209
218
  // "montags", "montags bis freitags", "montags, mittwochs und freitags".
210
219
  function weekdayQualifier(ir: IR): string {
211
- const segments = flattenSteps(fieldSegments(ir, 'weekday'));
220
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
221
+ // form. The IR stays canonical (Sunday=0). The helper flattens steps.
222
+ const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
212
223
 
213
224
  if (segments.length === 1 && segments[0].kind === 'range') {
214
225
  return weekdayRange(segments[0].bounds);
@@ -460,10 +471,21 @@ function countedPhrase(
460
471
  return 'in den ' + plural + ' ' + joinList(fieldValues(ir, field));
461
472
  }
462
473
 
463
- // The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
464
- // jeder Minute".
474
+ // The minute scope for a seconds clause: "jeder Minute" only when the minute
475
+ // is a wildcard (the seconds really do fire in every minute). A restricted
476
+ // minute (single/list/range/step) is named by its own clause, so the seconds
477
+ // clause drops the scope — "jeder Minute" would otherwise contradict the fixed
478
+ // minute ("in Sekunde 30 jeder Minute, in Minute 30" fires at second 30 of
479
+ // minute 30, not every minute).
480
+ function minuteAnchor(ir: IR): string {
481
+ return ir.pattern.minute === '*' ? 'jeder Minute' : '';
482
+ }
483
+
484
+ // The seconds clause: "alle 30 Sekunden" for a step, "in Sekunde 15 jeder
485
+ // Minute" under a wildcard minute, else the bare "in Sekunde 15" when the
486
+ // minute is fixed (its own clause names it).
465
487
  function secondsLead(ir: IR): string {
466
- return secondsClause(ir, 'jeder Minute');
488
+ return secondsClause(ir, minuteAnchor(ir));
467
489
  }
468
490
 
469
491
  // The second clause counted against an arbitrary anchor. The anchor is "jeder
@@ -486,7 +508,7 @@ function secondsClause(ir: IR, anchor: string): string {
486
508
  }
487
509
 
488
510
  return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
489
- countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
511
+ withAnchor(countedPhrase(ir, 'second', 'Sekunde', 'Sekunden'), anchor);
490
512
  }
491
513
 
492
514
  // A clock time that always shows its minutes: "9:00", "9:30".
@@ -670,6 +692,25 @@ function renderMinuteSpanInHour(
670
692
 
671
693
  // Seconds composed with the rest: "in den Sekunden 0 und 30 jeder Minute, um
672
694
  // 9:05 Uhr".
695
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
696
+ // cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
697
+ // them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
698
+ // English. Other strides, a restricted hour, and an hour cadence keep the
699
+ // juxtaposed form.
700
+ function isEveryOtherMinuteSeconds(
701
+ ir: IR,
702
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
703
+ ): boolean {
704
+ if (plan.rest.kind !== 'minuteFrequency' ||
705
+ ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
706
+ return false;
707
+ }
708
+
709
+ const minuteStep = stepSegment(ir.analyses.segments.minute);
710
+
711
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
712
+ }
713
+
673
714
  function renderComposeSeconds(
674
715
  ir: IR,
675
716
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -682,7 +723,8 @@ function renderComposeSeconds(
682
723
  if ((plan.rest.kind === 'clockTimes' ||
683
724
  plan.rest.kind === 'compactClockTimes') &&
684
725
  ir.shapes.minute === 'single') {
685
- const cadence = hourCadence(ir, +ir.pattern.minute);
726
+ const minute = +ir.pattern.minute;
727
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
686
728
 
687
729
  if (cadence !== null) {
688
730
  return cadence;
@@ -699,21 +741,21 @@ function renderComposeSeconds(
699
741
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
700
742
  }
701
743
 
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
- }
744
+ // A wildcard second under a minute */2 with a wildcard hour binds in the
745
+ // genitive ("jede Sekunde jeder zweiten Minute").
746
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
747
+ return secondsLead(ir) + ' jeder zweiten Minute';
714
748
  }
715
749
 
716
- return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
750
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
751
+ // leading clause, so the composer must not prepend a second lead that would
752
+ // double it. A wildcard or stepped second is not folded there (no
753
+ // clockSecond), so it still leads its own clause here.
754
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
755
+ ir.analyses.clockSecond;
756
+ const lead = restOwnsLead ? '' : secondsLead(ir) + ', ';
757
+
758
+ return lead + render(ir, plan.rest, opts);
717
759
  }
718
760
 
719
761
  // True when a compose-seconds plan is a sub-minute second over a minute-0
@@ -752,18 +794,30 @@ function renderMinutesAcrossHours(
752
794
  opts: Opts
753
795
  ): string {
754
796
  const sep = opts.style.sep;
797
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence,
798
+ // not a wall of hour columns.
799
+ const cadence = unevenHourCadence(ir);
755
800
 
756
801
  // The wildcard form means every minute *during* each hour: render windows.
757
802
  if (plan.form === 'wildcard') {
758
- return 'jede Minute ' + duringHours(ir, plan.times, sep);
803
+ return cadence ?
804
+ 'jede Minute, ' + cadence :
805
+ 'jede Minute ' + duringHours(ir, plan.times, sep);
806
+ }
807
+
808
+ const minuteLead =
809
+ strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
810
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten');
811
+
812
+ if (cadence !== null) {
813
+ return minuteLead + ', ' + cadence;
759
814
  }
760
815
 
761
816
  const hours = plan.times.kind === 'fires' ?
762
817
  atHours(plan.times.fires) :
763
818
  joinList(hourSegmentParts(ir, 0, 0, sep));
764
819
 
765
- return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
766
- countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
820
+ return minuteLead + ', ' + hours;
767
821
  }
768
822
 
769
823
  // A minute clause across a stepped hour range. A wildcard minute (a cadence)
@@ -774,6 +828,12 @@ function renderMinuteSpanAcrossHourStep(
774
828
  ir: IR,
775
829
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
776
830
  ): string {
831
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
832
+ // offset-clean stride keeps its "in jeder N-ten Stunde" confinement.
833
+ const cadence = unevenHourCadence(ir);
834
+
835
+ // A wildcard minute over a stepped hour is reached only for a clean stride (a
836
+ // bounded or uneven step routes through minutesAcrossHours instead).
777
837
  if (plan.form === 'wildcard') {
778
838
  return 'jede Minute ' +
779
839
  everyNthHour(stepSegment(ir.analyses.segments.hour));
@@ -782,11 +842,11 @@ function renderMinuteSpanAcrossHourStep(
782
842
  // The minute (range or list) leads; the hour trails. A clean stride confines
783
843
  // to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
784
844
  // minute-step compositions use, never a juxtaposed second frequency. A
785
- // bounded stride (reachable only via a range) enumerates its fires instead.
845
+ // bounded or uneven stride trails its endpoint-pinning cadence instead.
786
846
  const segment = stepSegment(ir.analyses.segments.hour);
787
- const hours = confinedHourStride(segment) ?
847
+ const hours = cadence ?? (confinedHourStride(segment) ?
788
848
  everyNthHour(segment) :
789
- atHours(segment.fires);
849
+ atHours(segment.fires));
790
850
 
791
851
  return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
792
852
  countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
@@ -805,10 +865,11 @@ function renderCompactClockTimes(
805
865
  const sep = opts.style.sep;
806
866
 
807
867
  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);
868
+ // An hour step or range (or arithmetic-progression hour list) under the
869
+ // single pinned minute reads as a cadence or window, not a wall of clock
870
+ // times. (Returns null for an irregular list, which keeps folding below.)
871
+ const cadence = hourCadence(ir, plan.minute) ??
872
+ hourRangeCadence(ir, plan.minute);
812
873
 
813
874
  if (cadence !== null) {
814
875
  return cadence;
@@ -821,11 +882,12 @@ function renderCompactClockTimes(
821
882
  joinList(hourSegmentParts(ir, plan.minute, ir.analyses.clockSecond, sep));
822
883
  }
823
884
 
824
- // A range among the hours reads as a window; otherwise a flat hour list.
825
- const hours = fieldSegments(ir, 'hour')
826
- .some((segment) => segment.kind === 'range') ?
827
- joinList(hourSegmentParts(ir, 0, 0, sep)) :
828
- atHours(hourFires(ir));
885
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; else
886
+ // a range among the hours reads as a window, otherwise a flat hour list.
887
+ const hours = unevenHourCadence(ir) ??
888
+ (fieldSegments(ir, 'hour').some((segment) => segment.kind === 'range') ?
889
+ joinList(hourSegmentParts(ir, 0, 0, sep)) :
890
+ atHours(hourFires(ir)));
829
891
 
830
892
  // A folded second has no single clock time to attach to here, so it leads
831
893
  // as its own clause ("in Sekunde 30, ..."). It is the bare second (not
@@ -848,17 +910,31 @@ function renderMinuteFrequency(
848
910
  const segment = stepSegment(ir.analyses.segments.minute);
849
911
  const sep = opts.style.sep;
850
912
  const clean = cleanStep(segment, 60);
851
- const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
852
913
 
853
914
  if (plan.hours.kind === 'window') {
915
+ // A single fixed hour (from === to) drops the "jeder Stunde" tail — the
916
+ // window names that one hour, so "jeder Stunde" (every hour) contradicts
917
+ // it. A range keeps it: the cadence truly repeats across each hour.
918
+ const singleHour = plan.hours.from === plan.hours.to;
919
+ const base = stepClause(segment, UNITS.minute,
920
+ singleHour ? '' : 'jeder Stunde');
854
921
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
855
922
  sep);
856
923
 
857
924
  return clean ? base + ' ' + window : base + ', ' + window;
858
925
  }
859
926
 
927
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
928
+
860
929
  if (plan.hours.kind === 'during') {
861
- return base + ' ' + duringHours(ir, plan.hours.times, sep);
930
+ // A bounded or uneven hour stride confines the minute cadence to its own
931
+ // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
932
+ // 20 Uhr").
933
+ const cadence = unevenHourCadence(ir);
934
+
935
+ return cadence ?
936
+ base + ', ' + cadence :
937
+ base + ' ' + duringHours(ir, plan.hours.times, sep);
862
938
  }
863
939
 
864
940
  if (plan.hours.kind === 'step') {
@@ -871,15 +947,50 @@ function renderMinuteFrequency(
871
947
  return base;
872
948
  }
873
949
 
874
- // A stepped hour field as a phrase: the cadence when clean ("alle 6 Stunden"),
875
- // else its discrete fires when uneven or bounded ("um 0, 5, 10, 15 und 20
876
- // Uhr"). Shared by the bare hour step and the minute-step compositions.
950
+ // A stepped hour field as a phrase: a clean stride from midnight is the bare
951
+ // cadence ("alle 2 Stunden"); an open offset-clean stride names only its start
952
+ // ("alle 2 Stunden ab 1 Uhr") since it wraps the day with no distinct
953
+ // endpoint; a bounded or uneven stride pins both ends ("alle 2 Stunden von 9
954
+ // bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
955
+ // An explicitly bounded step (`a-b/n`) keeps its enumerated hours, matching
956
+ // en/fi/zh; only an OPEN step (`m/n`) reads as the wrapping cadence.
877
957
  function hourStepPhrase(ir: IR): string {
958
+ const cadence = unevenHourCadence(ir);
959
+
960
+ if (cadence !== null) {
961
+ return cadence;
962
+ }
963
+
878
964
  const segment = stepSegment(ir.analyses.segments.hour);
879
965
 
880
- return cleanStep(segment, 24) ?
881
- everyN(segment.interval, UNITS.hour) :
882
- atHours(segment.fires);
966
+ if (cleanStep(segment, 24)) {
967
+ return everyN(segment.interval, UNITS.hour);
968
+ }
969
+
970
+ // An open offset-clean step (`m/n`, m < n dividing 24) wraps the day with no
971
+ // endpoint: name only its start, the cadence en/fi/zh and the compose paths
972
+ // already speak — never the enumerated hour list. A bounded `a-b/n` keeps its
973
+ // explicit hours.
974
+ const stride = openOffsetCleanStride(ir, segment);
975
+
976
+ return stride ? hourStrideCadence(stride) : atHours(segment.fires);
977
+ }
978
+
979
+ // The stride of an OPEN offset-clean hour step (`m/n`, m < n dividing 24),
980
+ // or null for any other step: such a step wraps the day with no endpoint and
981
+ // reads as the "alle N Stunden ab M Uhr" cadence. An explicitly bounded step
982
+ // (`a-b/n`, startToken carries a `-`) is excluded so it keeps its enumerated
983
+ // hours, matching en/fi/zh.
984
+ function openOffsetCleanStride(
985
+ ir: IR, segment: StepSegment
986
+ ): {start: number; interval: number; last: number} | null {
987
+ if (segment.startToken.indexOf('-') !== -1) {
988
+ return null;
989
+ }
990
+
991
+ const stride = hourStride(ir);
992
+
993
+ return stride && offsetCleanStride(stride) ? stride : null;
883
994
  }
884
995
 
885
996
  // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
@@ -909,12 +1020,56 @@ function hourStrideCadence(
909
1020
  return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
910
1021
  }
911
1022
 
1023
+ // An hour list's arithmetic progression, or null when its values are not a step
1024
+ // the renderer should speak as a cadence. The core rewrites a uneven hour step
1025
+ // (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
1026
+ // fire list, indistinguishable in the IR from a hand-written list; the renderer
1027
+ // recovers the cadence from the values. A progression starting at zero is a
1028
+ // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
1029
+ // a step when it is too long to be a deliberate clock-time list (9,17 is two
1030
+ // named times, not a cadence). Interval one is a plain range, never a step.
1031
+ function hourListStride(
1032
+ values: number[]
1033
+ ): {start: number; interval: number; last: number} | null {
1034
+ if (values.length < 2) {
1035
+ return null;
1036
+ }
1037
+
1038
+ const interval = values[1] - values[0];
1039
+
1040
+ if (interval < 2) {
1041
+ return null;
1042
+ }
1043
+
1044
+ for (let i = 2; i < values.length; i += 1) {
1045
+ if (values[i] - values[i - 1] !== interval) {
1046
+ return null;
1047
+ }
1048
+ }
1049
+
1050
+ if (values[0] !== 0 && values.length < 5) {
1051
+ return null;
1052
+ }
1053
+
1054
+ return {interval, last: values[values.length - 1], start: values[0]};
1055
+ }
1056
+
1057
+ // Whether an hour stride wraps the day cleanly from within its first interval
1058
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1059
+ // stride has no distinct endpoint and keeps its bare or "ab" cadence. Every
1060
+ // other stride — a uneven interval, or one starting at or past its interval (a
1061
+ // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1062
+ function offsetCleanStride(
1063
+ stride: {start: number; interval: number}
1064
+ ): boolean {
1065
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1066
+ }
1067
+
912
1068
  // The hour field's stride, or null when the hour is not a cadence: a step
913
1069
  // 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.
1070
+ // yields one only when its values form a step progression (so an irregular list
1071
+ // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1072
+ // the stride and speaks it as a cadence, not the clock-time cross-product.
918
1073
  function hourStride(
919
1074
  ir: IR
920
1075
  ): {start: number; interval: number; last: number} | null {
@@ -927,6 +1082,13 @@ function hourStride(
927
1082
 
928
1083
  if (segments.length === 1 && segments[0].kind === 'step') {
929
1084
  const segment = segments[0];
1085
+
1086
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1087
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1088
+ if (segment.fires.length < 2) {
1089
+ return null;
1090
+ }
1091
+
930
1092
  const start = segment.startToken === '*' ?
931
1093
  0 :
932
1094
  +segment.startToken.split('-')[0];
@@ -936,9 +1098,25 @@ function hourStride(
936
1098
  }
937
1099
 
938
1100
  const values = singleValues(segments);
939
- const step = values && arithmeticStep(values);
940
1101
 
941
- return step || null;
1102
+ return values && hourListStride(values);
1103
+ }
1104
+
1105
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1106
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1107
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1108
+ // enumerating list paths; there the bounded hour reads better as its cadence
1109
+ // ("…, alle 5 Stunden von 0 bis 20 Uhr") than as a wall of clock times. An
1110
+ // offset-clean stride keeps its existing confinement form, so only the
1111
+ // endpoint-bearing case routes here.
1112
+ function unevenHourCadence(ir: IR): string | null {
1113
+ const stride = hourStride(ir);
1114
+
1115
+ if (!stride || offsetCleanStride(stride)) {
1116
+ return null;
1117
+ }
1118
+
1119
+ return hourStrideCadence(stride);
942
1120
  }
943
1121
 
944
1122
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -958,7 +1136,7 @@ function subMinuteSecond(ir: IR): boolean {
958
1136
  function hourCadenceLead(ir: IR, minute: number): string {
959
1137
  if (minute === 0) {
960
1138
  if (subMinuteSecond(ir)) {
961
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
1139
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute');
962
1140
  }
963
1141
 
964
1142
  return secondsClause(ir, 'jeder Stunde');
@@ -972,7 +1150,7 @@ function hourCadenceLead(ir: IR, minute: number): string {
972
1150
  return minutePhrase;
973
1151
  }
974
1152
 
975
- return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
1153
+ return secondsClause(ir, minuteAnchor(ir)) + ', ' + minutePhrase;
976
1154
  }
977
1155
 
978
1156
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -994,7 +1172,13 @@ function hourCadence(ir: IR, minute: number): string | null {
994
1172
 
995
1173
  const fires = (stride.last - stride.start) / stride.interval + 1;
996
1174
 
997
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1175
+ // A short stride that spells out as few clock times stays an enumeration only
1176
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1177
+ // or "ab" form is no shorter than the list. A bounded or uneven stride has no
1178
+ // clean wrap, so its endpoint-pinning cadence ("alle 5 Stunden von 0 bis 20
1179
+ // Uhr") reads better however short.
1180
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1181
+ offsetCleanStride(stride)) {
998
1182
  return null;
999
1183
  }
1000
1184
 
@@ -1008,19 +1192,72 @@ function hourCadence(ir: IR, minute: number): string | null {
1008
1192
  confinedHourStride(segment);
1009
1193
 
1010
1194
  if (confined) {
1011
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
1012
- everyNthHour(segment);
1195
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute') +
1196
+ ' ' + everyNthHour(segment);
1197
+ }
1198
+
1199
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1200
+ // lead clause to fold in, so the bounded cadence stands on its own ("alle 5
1201
+ // Stunden von 0 bis 20 Uhr").
1202
+ if (minute === 0 && ir.pattern.second === '0') {
1203
+ return hourStrideCadence(stride);
1013
1204
  }
1014
1205
 
1015
1206
  return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
1016
1207
  }
1017
1208
 
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.
1209
+ // Whether an hour cadence or hour-range window applies to a plan with a single
1210
+ // pinned minute — the signal that the clause is a cadence/window, not a daily
1211
+ // clock-time list, so the "täglich" frame must not be added.
1021
1212
  function hourCadenceApplies(ir: IR): boolean {
1022
- return ir.shapes.minute === 'single' &&
1023
- hourCadence(ir, +ir.pattern.minute) !== null;
1213
+ if (ir.shapes.minute !== 'single') {
1214
+ return false;
1215
+ }
1216
+
1217
+ const minute = +ir.pattern.minute;
1218
+
1219
+ return hourCadence(ir, minute) !== null ||
1220
+ hourRangeCadence(ir, minute) !== null;
1221
+ }
1222
+
1223
+ // Whether the hour field is a range — or a list whose segments include a
1224
+ // range — and so forms a window rather than a cross-product of clock times.
1225
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1226
+ // a step is handled by hourStride/hourCadence.
1227
+ function hasHourWindow(ir: IR): boolean {
1228
+ const segments = fieldSegments(ir, 'hour');
1229
+
1230
+ return !!segments && segments.some(function range(segment) {
1231
+ return segment.kind === 'range';
1232
+ });
1233
+ }
1234
+
1235
+ // Render an hour range (or a list whose segments include a range) under
1236
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1237
+ // then "von 9 bis 17 Uhr" (and any non-contiguous hour as "und um 22 Uhr") —
1238
+ // instead of cross-multiplying the hours into a wall of clock times. The
1239
+ // hour-RANGE analog of hourCadence; returns the bare clause (the day frame is
1240
+ // suppressed by hourCadenceApplies). Returns null when the hour has no range,
1241
+ // when the minute is non-zero (a real clock minute the existing window form
1242
+ // already speaks), or when a plain :00 set carries no clause. Renderer-only;
1243
+ // the IR is unchanged.
1244
+ function hourRangeCadence(ir: IR, minute: number): string | null {
1245
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1246
+ return null;
1247
+ }
1248
+
1249
+ return hourCadenceLead(ir, minute) + ', ' + hourRangeWindowTail(ir);
1250
+ }
1251
+
1252
+ // The hour-range window as a cadence tail at the top of each hour: each range
1253
+ // segment is "von X bis Y Uhr", any non-contiguous hour is "um Z Uhr", joined
1254
+ // — the same parts the bare "stündlich von 9 bis 17 Uhr" window forms, minus
1255
+ // the "stündlich" prefix the lead replaces. The minute has folded into the
1256
+ // lead, so the parts close on the top of their final hour.
1257
+ function hourRangeWindowTail(ir: IR): string {
1258
+ // Minute 0 with a falsy second renders each part as a bare hour ("von 9 bis
1259
+ // 17 Uhr", "um 22 Uhr"); the separator is unused in that path.
1260
+ return joinList(hourSegmentParts(ir, 0, 0, ':'));
1024
1261
  }
1025
1262
 
1026
1263
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
@@ -1029,11 +1266,14 @@ function renderHourRange(
1029
1266
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
1030
1267
  opts: Opts
1031
1268
  ): string {
1032
- // A bare close (`boundMinute` null) lands on the top of the final hour
1033
- // (minute 0), matching the minute-0 baseline, with the minutes stated
1034
- // separately; a single fire or wildcard names an exact closing minute.
1035
- const window = hourWindow(plan.from, plan.to, plan.boundMinute ?? 0,
1036
- opts.style.sep);
1269
+ // The close lands on the top of the final hour (minute 0) unless the minute
1270
+ // genuinely runs to the end of that hour i.e. a wildcard minute, which
1271
+ // fills every minute and states no separate clause. A pinned/listed/ranged
1272
+ // minute is named in its own lead clause, so folding it into the close too
1273
+ // would read as a span ("bis 17:05 Uhr") that contradicts the minute clause;
1274
+ // the window stays bare ("bis 17 Uhr").
1275
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
1276
+ const window = hourWindow(plan.from, plan.to, last, opts.style.sep);
1037
1277
 
1038
1278
  if (plan.minuteForm === 'wildcard') {
1039
1279
  return 'jede Minute ' + window;
@@ -1043,9 +1283,15 @@ function renderHourRange(
1043
1283
  return 'stündlich ' + window;
1044
1284
  }
1045
1285
 
1046
- // A non-zero single minute ('lead') or a minute range leads the window.
1047
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') +
1048
- ' jeder Stunde, ' + window;
1286
+ // A non-zero single minute ('lead') or a minute range leads the window. A
1287
+ // non-uniform minute step the core enumerated to a fire list reads as its
1288
+ // bounded cadence ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde") instead
1289
+ // of the wall of fires; an irregular list or a single minute keeps the
1290
+ // counted form.
1291
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
1292
+ 'jeder Stunde') ??
1293
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
1294
+ ', ' + window;
1049
1295
  }
1050
1296
 
1051
1297
  // One or more clock times: "um 9 Uhr", "um 14:30 Uhr", "um 9 und 17 Uhr".
@@ -1054,10 +1300,12 @@ function renderClockTimes(
1054
1300
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
1055
1301
  opts: Opts
1056
1302
  ): 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.
1303
+ // An hour step or range (or arithmetic-progression hour list) under a single
1304
+ // pinned minute reads as a cadence or window rather than a cross-product of
1305
+ // clock times.
1059
1306
  if (ir.shapes.minute === 'single') {
1060
- const cadence = hourCadence(ir, +ir.pattern.minute);
1307
+ const minute = +ir.pattern.minute;
1308
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
1061
1309
 
1062
1310
  if (cadence !== null) {
1063
1311
  return cadence;
@@ -1156,8 +1404,17 @@ function needsDailyFrame(ir: IR): boolean {
1156
1404
  return true;
1157
1405
  }
1158
1406
 
1159
- return ir.plan.kind === 'hourStep' &&
1160
- !cleanStep(stepSegment(ir.analyses.segments.hour), 24);
1407
+ if (ir.plan.kind !== 'hourStep') {
1408
+ return false;
1409
+ }
1410
+
1411
+ // An hour step rendered as a cadence ("alle N Stunden [ab M Uhr]") is a
1412
+ // frequency, not a daily clock-time list, so it takes no "täglich" frame —
1413
+ // only a bounded `a-b/n` step that enumerates its hours ("um 1, 3, … Uhr")
1414
+ // needs the recurring frame.
1415
+ const segment = stepSegment(ir.analyses.segments.hour);
1416
+
1417
+ return !cleanStep(segment, 24) && !openOffsetCleanStride(ir, segment);
1161
1418
  }
1162
1419
 
1163
1420
  function render(ir: IR, plan: PlanNode, opts: Opts): string {