cronli5 0.1.6 → 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.
package/dist/lang/zh.cjs CHANGED
@@ -44,6 +44,26 @@ function arithmeticStep(values) {
44
44
  }
45
45
  return { start: values[0], interval, last: values[values.length - 1] };
46
46
  }
47
+ function weekdayDisplayKey(value) {
48
+ return value === 0 ? 7 : value;
49
+ }
50
+ function orderWeekdaysForDisplay(segments) {
51
+ const flattened = segments.flatMap(function flat(segment) {
52
+ return segment.kind === "step" ? segment.fires.map(function single(value) {
53
+ return { kind: "single", value: "" + value };
54
+ }) : [segment];
55
+ });
56
+ function key(segment) {
57
+ return segment.kind === "range" ? weekdayDisplayKey(+segment.bounds[0]) : weekdayDisplayKey(+segment.value);
58
+ }
59
+ return flattened.map(function index(segment, position) {
60
+ return [segment, position];
61
+ }).sort(function byDisplayKey(a, b) {
62
+ return key(a[0]) - key(b[0]) || a[1] - b[1];
63
+ }).map(function unwrap(pair) {
64
+ return pair[0];
65
+ });
66
+ }
47
67
  function toFieldNumber(token, numberMap) {
48
68
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
49
69
  }
@@ -384,6 +404,15 @@ function hourCadencePhrase(ir) {
384
404
  anchor: ""
385
405
  });
386
406
  }
407
+ function minuteZeroConfinement(ir, stride, prefix) {
408
+ if (stride.interval === 2 && stride.start === 0) {
409
+ return "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir);
410
+ }
411
+ if (prefix.indexOf("\u4ECE") !== -1) {
412
+ return prefix + "0\u5206" + secondTail(ir);
413
+ }
414
+ return null;
415
+ }
387
416
  function hourCadence(ir) {
388
417
  const stride = hourStride(ir);
389
418
  if (!stride) {
@@ -400,7 +429,7 @@ function hourCadence(ir) {
400
429
  const minute = +ir.pattern.minute;
401
430
  const subMinute = ir.pattern.second === "*" || ir.shapes.second === "step";
402
431
  if (minute === 0 && subMinute) {
403
- return stride.interval === 2 && stride.start === 0 ? "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir) : null;
432
+ return minuteZeroConfinement(ir, stride, prefix);
404
433
  }
405
434
  if (minute === 0) {
406
435
  return prefix + "0\u5206" + secondTail(ir);
@@ -634,7 +663,7 @@ function quartzDate(token, monthPrefix) {
634
663
  if (token.startsWith("L-")) {
635
664
  return monthPrefix + "\u6700\u540E\u7B2C" + token.slice(2) + "\u5929";
636
665
  }
637
- return "\u6700\u63A5\u8FD1" + token.slice(0, -1) + "\u65E5\u7684\u5DE5\u4F5C\u65E5";
666
+ return monthPrefix + "\u6700\u63A5\u8FD1" + token.slice(0, -1) + "\u65E5\u7684\u5DE5\u4F5C\u65E5";
638
667
  }
639
668
  function datePhrase(ir) {
640
669
  const month = monthPhrase(ir);
@@ -648,7 +677,11 @@ function datePhrase(ir) {
648
677
  if (ir.shapes.date === "step") {
649
678
  return month + cadence(stepSegment(ir, "date").interval, "\u5929");
650
679
  }
651
- return month ? month + dayList(ir) : "\u6BCF\u6708" + dayList(ir);
680
+ if (!month) {
681
+ return "\u6BCF\u6708" + dayList(ir);
682
+ }
683
+ const monthMulti = ir.shapes.month === "range" || ir.shapes.month === "list";
684
+ return month + (monthMulti ? "\uFF0C" : "") + dayList(ir);
652
685
  }
653
686
  function dateCore(ir, quartzPrefix) {
654
687
  if (ir.shapes.date === "quartz") {
@@ -690,10 +723,8 @@ function weekdayPhrase(ir, orContext, monthPrefix) {
690
723
  return "\u6BCF" + weekdayName(from) + "\u81F3" + weekdayName(to);
691
724
  }
692
725
  const days = [];
693
- segs.forEach(function expand(seg) {
694
- if (seg.kind === "step") {
695
- days.push(...seg.fires);
696
- } else if (seg.kind === "single") {
726
+ orderWeekdaysForDisplay(segs).forEach(function expand(seg) {
727
+ if (seg.kind === "single") {
697
728
  days.push(toFieldNumber(seg.value, weekdayNumbers));
698
729
  }
699
730
  });
package/dist/lang/zh.js CHANGED
@@ -18,6 +18,26 @@ function arithmeticStep(values) {
18
18
  }
19
19
  return { start: values[0], interval, last: values[values.length - 1] };
20
20
  }
21
+ function weekdayDisplayKey(value) {
22
+ return value === 0 ? 7 : value;
23
+ }
24
+ function orderWeekdaysForDisplay(segments) {
25
+ const flattened = segments.flatMap(function flat(segment) {
26
+ return segment.kind === "step" ? segment.fires.map(function single(value) {
27
+ return { kind: "single", value: "" + value };
28
+ }) : [segment];
29
+ });
30
+ function key(segment) {
31
+ return segment.kind === "range" ? weekdayDisplayKey(+segment.bounds[0]) : weekdayDisplayKey(+segment.value);
32
+ }
33
+ return flattened.map(function index(segment, position) {
34
+ return [segment, position];
35
+ }).sort(function byDisplayKey(a, b) {
36
+ return key(a[0]) - key(b[0]) || a[1] - b[1];
37
+ }).map(function unwrap(pair) {
38
+ return pair[0];
39
+ });
40
+ }
21
41
  function toFieldNumber(token, numberMap) {
22
42
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
23
43
  }
@@ -358,6 +378,15 @@ function hourCadencePhrase(ir) {
358
378
  anchor: ""
359
379
  });
360
380
  }
381
+ function minuteZeroConfinement(ir, stride, prefix) {
382
+ if (stride.interval === 2 && stride.start === 0) {
383
+ return "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir);
384
+ }
385
+ if (prefix.indexOf("\u4ECE") !== -1) {
386
+ return prefix + "0\u5206" + secondTail(ir);
387
+ }
388
+ return null;
389
+ }
361
390
  function hourCadence(ir) {
362
391
  const stride = hourStride(ir);
363
392
  if (!stride) {
@@ -374,7 +403,7 @@ function hourCadence(ir) {
374
403
  const minute = +ir.pattern.minute;
375
404
  const subMinute = ir.pattern.second === "*" || ir.shapes.second === "step";
376
405
  if (minute === 0 && subMinute) {
377
- return stride.interval === 2 && stride.start === 0 ? "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir) : null;
406
+ return minuteZeroConfinement(ir, stride, prefix);
378
407
  }
379
408
  if (minute === 0) {
380
409
  return prefix + "0\u5206" + secondTail(ir);
@@ -608,7 +637,7 @@ function quartzDate(token, monthPrefix) {
608
637
  if (token.startsWith("L-")) {
609
638
  return monthPrefix + "\u6700\u540E\u7B2C" + token.slice(2) + "\u5929";
610
639
  }
611
- return "\u6700\u63A5\u8FD1" + token.slice(0, -1) + "\u65E5\u7684\u5DE5\u4F5C\u65E5";
640
+ return monthPrefix + "\u6700\u63A5\u8FD1" + token.slice(0, -1) + "\u65E5\u7684\u5DE5\u4F5C\u65E5";
612
641
  }
613
642
  function datePhrase(ir) {
614
643
  const month = monthPhrase(ir);
@@ -622,7 +651,11 @@ function datePhrase(ir) {
622
651
  if (ir.shapes.date === "step") {
623
652
  return month + cadence(stepSegment(ir, "date").interval, "\u5929");
624
653
  }
625
- return month ? month + dayList(ir) : "\u6BCF\u6708" + dayList(ir);
654
+ if (!month) {
655
+ return "\u6BCF\u6708" + dayList(ir);
656
+ }
657
+ const monthMulti = ir.shapes.month === "range" || ir.shapes.month === "list";
658
+ return month + (monthMulti ? "\uFF0C" : "") + dayList(ir);
626
659
  }
627
660
  function dateCore(ir, quartzPrefix) {
628
661
  if (ir.shapes.date === "quartz") {
@@ -664,10 +697,8 @@ function weekdayPhrase(ir, orContext, monthPrefix) {
664
697
  return "\u6BCF" + weekdayName(from) + "\u81F3" + weekdayName(to);
665
698
  }
666
699
  const days = [];
667
- segs.forEach(function expand(seg) {
668
- if (seg.kind === "step") {
669
- days.push(...seg.fires);
670
- } else if (seg.kind === "single") {
700
+ orderWeekdaysForDisplay(segs).forEach(function expand(seg) {
701
+ if (seg.kind === "single") {
671
702
  days.push(toFieldNumber(seg.value, weekdayNumbers));
672
703
  }
673
704
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
package/src/core/util.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Small shared utilities for the core.
2
2
 
3
+ import type {Segment} from './ir.js';
4
+
3
5
  function includes(str: string | number, sub: string): boolean {
4
6
  return ('' + str).indexOf(sub) !== -1;
5
7
  }
@@ -44,6 +46,54 @@ function arithmeticStep(values: number[]):
44
46
  return {start: values[0], interval, last: values[values.length - 1]};
45
47
  }
46
48
 
49
+ // The display sort key for a canonical weekday number: Monday (1) first,
50
+ // Sunday (0) last. The IR keeps Sunday=0 canonical; this is display-only.
51
+ function weekdayDisplayKey(value: number): number {
52
+ return value === 0 ? 7 : value;
53
+ }
54
+
55
+ // A weekday display segment: a single day or a (possibly wrap) range. Steps
56
+ // are flattened away into singles before sorting, so the result is only these
57
+ // two kinds; each renderer turns them into names exactly as it does today.
58
+ type WeekdaySegment =
59
+ | {kind: 'single'; value: string}
60
+ | {kind: 'range'; bounds: [string, string]};
61
+
62
+ // Reorder weekday segments Monday-first (Sunday last) for display, so a weekend
63
+ // list reads "Saturday and Sunday" rather than the canonical Sunday-first
64
+ // "Sunday and Saturday". Display-only: the IR / canonical order is unchanged (a
65
+ // fresh array is returned). A step expands to its fires as singles so the days
66
+ // sort into the list; a range stays one unit and keeps its own bounds order (a
67
+ // wrap range is not reordered into a list), sorting by its opening bound — so a
68
+ // lone range sorts to a one-element list and is unchanged. The sort is stable,
69
+ // so equal opening days keep input order.
70
+ function orderWeekdaysForDisplay(segments: Segment[]): WeekdaySegment[] {
71
+ const flattened: WeekdaySegment[] = segments.flatMap(function flat(segment) {
72
+ return segment.kind === 'step' ?
73
+ segment.fires.map(function single(value): WeekdaySegment {
74
+ return {kind: 'single', value: '' + value};
75
+ }) :
76
+ [segment];
77
+ });
78
+
79
+ function key(segment: WeekdaySegment): number {
80
+ return segment.kind === 'range' ?
81
+ weekdayDisplayKey(+segment.bounds[0]) :
82
+ weekdayDisplayKey(+segment.value);
83
+ }
84
+
85
+ return flattened
86
+ .map(function index(segment, position): [WeekdaySegment, number] {
87
+ return [segment, position];
88
+ })
89
+ .sort(function byDisplayKey(a, b): number {
90
+ return key(a[0]) - key(b[0]) || a[1] - b[1];
91
+ })
92
+ .map(function unwrap(pair): WeekdaySegment {
93
+ return pair[0];
94
+ });
95
+ }
96
+
47
97
  // Resolve a numeric or named field token (e.g. '5' or 'FRI') to its number.
48
98
  function toFieldNumber(
49
99
  token: string,
@@ -54,5 +104,6 @@ function toFieldNumber(
54
104
  return isNonNegativeInteger(token) ? +token : numberMap![token.toUpperCase()];
55
105
  }
56
106
  export {
57
- arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique
107
+ arithmeticStep, includes, isNonNegativeInteger, orderWeekdaysForDisplay,
108
+ toFieldNumber, unique
58
109
  };
@@ -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".
@@ -888,15 +910,22 @@ function renderMinuteFrequency(
888
910
  const segment = stepSegment(ir.analyses.segments.minute);
889
911
  const sep = opts.style.sep;
890
912
  const clean = cleanStep(segment, 60);
891
- const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
892
913
 
893
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');
894
921
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
895
922
  sep);
896
923
 
897
924
  return clean ? base + ' ' + window : base + ', ' + window;
898
925
  }
899
926
 
927
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
928
+
900
929
  if (plan.hours.kind === 'during') {
901
930
  // A bounded or uneven hour stride confines the minute cadence to its own
902
931
  // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
@@ -918,9 +947,13 @@ function renderMinuteFrequency(
918
947
  return base;
919
948
  }
920
949
 
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
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
923
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.
924
957
  function hourStepPhrase(ir: IR): string {
925
958
  const cadence = unevenHourCadence(ir);
926
959
 
@@ -930,9 +963,34 @@ function hourStepPhrase(ir: IR): string {
930
963
 
931
964
  const segment = stepSegment(ir.analyses.segments.hour);
932
965
 
933
- return cleanStep(segment, 24) ?
934
- everyN(segment.interval, UNITS.hour) :
935
- 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;
936
994
  }
937
995
 
938
996
  // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
@@ -1078,7 +1136,7 @@ function subMinuteSecond(ir: IR): boolean {
1078
1136
  function hourCadenceLead(ir: IR, minute: number): string {
1079
1137
  if (minute === 0) {
1080
1138
  if (subMinuteSecond(ir)) {
1081
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
1139
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute');
1082
1140
  }
1083
1141
 
1084
1142
  return secondsClause(ir, 'jeder Stunde');
@@ -1092,7 +1150,7 @@ function hourCadenceLead(ir: IR, minute: number): string {
1092
1150
  return minutePhrase;
1093
1151
  }
1094
1152
 
1095
- return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
1153
+ return secondsClause(ir, minuteAnchor(ir)) + ', ' + minutePhrase;
1096
1154
  }
1097
1155
 
1098
1156
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1134,8 +1192,8 @@ function hourCadence(ir: IR, minute: number): string | null {
1134
1192
  confinedHourStride(segment);
1135
1193
 
1136
1194
  if (confined) {
1137
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
1138
- everyNthHour(segment);
1195
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute') +
1196
+ ' ' + everyNthHour(segment);
1139
1197
  }
1140
1198
 
1141
1199
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
@@ -1208,11 +1266,14 @@ function renderHourRange(
1208
1266
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
1209
1267
  opts: Opts
1210
1268
  ): string {
1211
- // A bare close (`boundMinute` null) lands on the top of the final hour
1212
- // (minute 0), matching the minute-0 baseline, with the minutes stated
1213
- // separately; a single fire or wildcard names an exact closing minute.
1214
- const window = hourWindow(plan.from, plan.to, plan.boundMinute ?? 0,
1215
- 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);
1216
1277
 
1217
1278
  if (plan.minuteForm === 'wildcard') {
1218
1279
  return 'jede Minute ' + window;
@@ -1343,8 +1404,17 @@ function needsDailyFrame(ir: IR): boolean {
1343
1404
  return true;
1344
1405
  }
1345
1406
 
1346
- return ir.plan.kind === 'hourStep' &&
1347
- !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);
1348
1418
  }
1349
1419
 
1350
1420
  function render(ir: IR, plan: PlanNode, opts: Opts): string {
@@ -3,7 +3,7 @@
3
3
  // the core stays semantic, and this module's only input is the IR.
4
4
  // See docs/i18n-design.md.
5
5
 
6
- import {arithmeticStep} from '../../core/util.js';
6
+ import {arithmeticStep, orderWeekdaysForDisplay} from '../../core/util.js';
7
7
  import {maxClockTimes} from '../../core/specs.js';
8
8
  import {clockDigits, numeral} from '../../core/format.js';
9
9
  import type {Cronli5Options} from '../../types.js';
@@ -596,12 +596,17 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
596
596
  trailingQualifier(ir, opts);
597
597
  }
598
598
 
599
- // The hour-range plan as a window whose closing minute honors `boundMinute`:
600
- // a bare close (`null`) lands on the top of the final hour (`:00`), matching
601
- // the minute-0 baseline, with the minutes stated separately elsewhere.
599
+ // The hour-range plan as a window. The close lands on the top of the final
600
+ // hour (`:00`) unless the minute genuinely runs to the end of that hour i.e.
601
+ // a wildcard minute, which fills every minute and states no separate clause.
602
+ // A pinned/listed/ranged minute is named in its own lead clause, so folding it
603
+ // into the close too would read as a span ("through 5:05 p.m.") that
604
+ // contradicts the minute clause; the window stays bare ("through 5 p.m.").
602
605
  function boundedWindow(plan: PlanOf<'hourRange'>):
603
606
  {from: number; to: number; last: number} {
604
- return {from: plan.from, last: plan.boundMinute ?? 0, to: plan.to};
607
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
608
+
609
+ return {from: plan.from, last, to: plan.to};
605
610
  }
606
611
 
607
612
  // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.". Windows
@@ -1499,29 +1504,51 @@ function monthFoldsIntoDate(ir: IR): boolean {
1499
1504
 
1500
1505
  // Compose the "day-of-month or day-of-week" phrase used when both fields
1501
1506
  // are restricted: cron fires when either is a match. A restricted month
1502
- // scopes both.
1507
+ // scopes BOTH halves, so it attaches to the whole or, never to a single
1508
+ // branch. When the month folds into a calendar date ("on June 13") it also
1509
+ // names itself on the weekday ("or on Friday in June"), keeping both halves
1510
+ // scoped; otherwise (a Quartz date, an open day step, a month range, or the
1511
+ // odd/even frequency) it trails the whole or as ", in <month>".
1503
1512
  function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
1504
1513
  const pattern = ir.pattern;
1505
1514
  const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
1506
1515
  'on ' + weekdayPhrase(ir, opts);
1516
+
1517
+ if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
1518
+ !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
1519
+ return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
1520
+ ' in ' + monthName(ir, opts);
1521
+ }
1522
+
1523
+ return datePart(ir, opts) + ' or ' + weekdayPart + orMonthScope(ir, opts);
1524
+ }
1525
+
1526
+ // The day-of-month half of an or-day phrase, without any month scope (the
1527
+ // month scopes the whole or, applied by the caller).
1528
+ function datePart(ir: IR, opts: NormalizedOptions): string {
1529
+ const pattern = ir.pattern;
1507
1530
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1508
1531
 
1509
1532
  if (quartzDate) {
1510
- return quartzDate + monthScope(ir, opts) + ' or ' + weekdayPart;
1533
+ return quartzDate;
1511
1534
  }
1512
1535
 
1513
1536
  if (isOpenStep(pattern.date)) {
1514
- return stepDates(pattern.date) + monthScope(ir, opts) + ' or ' +
1515
- weekdayPart;
1537
+ return stepDates(pattern.date);
1516
1538
  }
1517
1539
 
1518
- if (pattern.month !== '*' && monthFoldsIntoDate(ir)) {
1519
- return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
1520
- ' in ' + monthName(ir, opts);
1540
+ return 'on the ' + dateOrdinals(ir, opts);
1541
+ }
1542
+
1543
+ // A trailing month scope for the whole or, set off by a comma so it reads
1544
+ // over both day halves ("…or on Friday, in June"); empty when the month is a
1545
+ // wildcard.
1546
+ function orMonthScope(ir: IR, opts: NormalizedOptions): string {
1547
+ if (ir.pattern.month === '*') {
1548
+ return '';
1521
1549
  }
1522
1550
 
1523
- return 'on the ' + dateOrdinals(ir, opts) + ' or ' + weekdayPart +
1524
- monthScope(ir, opts);
1551
+ return ', in ' + monthName(ir, opts);
1525
1552
  }
1526
1553
 
1527
1554
  // The day-qualifier phrase for a Quartz date field (e.g. "on the last day
@@ -1665,8 +1692,12 @@ function oddEvenMonth(monthField: string): string | null {
1665
1692
  // Render the weekday field as names. Ranges read in their connective form
1666
1693
  // ("Monday through Friday", or "Mon-Fri" with `short`).
1667
1694
  function weekdayPhrase(ir: IR, opts: NormalizedOptions): string {
1668
- // Reached only with a restricted weekday, which has segments.
1669
- return renderSegments(ir.analyses.segments.weekday!, function name(value) {
1695
+ // Reached only with a restricted weekday, which has segments. Weekday lists
1696
+ // display Monday-first (Sunday last) so a weekend reads naturally; the IR
1697
+ // stays canonical (Sunday=0) and ranges keep their form.
1698
+ const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
1699
+
1700
+ return renderSegments(segments, function name(value) {
1670
1701
  return getWeekday(value, opts);
1671
1702
  }, opts);
1672
1703
  }