cronli5 0.2.0 → 0.3.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +4 -4
  3. package/cronli5.min.js +2 -2
  4. package/dist/cronli5.cjs +514 -407
  5. package/dist/cronli5.js +514 -407
  6. package/dist/lang/de.cjs +296 -225
  7. package/dist/lang/de.js +296 -225
  8. package/dist/lang/en.cjs +471 -364
  9. package/dist/lang/en.js +471 -364
  10. package/dist/lang/es.cjs +318 -281
  11. package/dist/lang/es.js +318 -281
  12. package/dist/lang/fi.cjs +326 -276
  13. package/dist/lang/fi.js +326 -276
  14. package/dist/lang/zh.cjs +308 -236
  15. package/dist/lang/zh.js +308 -236
  16. package/package.json +1 -1
  17. package/src/core/analyze.ts +22 -21
  18. package/src/core/cadence.ts +164 -0
  19. package/src/core/index.ts +3 -1
  20. package/src/core/normalize.ts +3 -3
  21. package/src/core/parse.ts +1 -1
  22. package/src/core/{ir.ts → schedule.ts} +23 -24
  23. package/src/core/shapes.ts +8 -1
  24. package/src/core/specs.ts +1 -1
  25. package/src/core/util.ts +4 -83
  26. package/src/core/validate.ts +2 -2
  27. package/src/core/weekday.ts +54 -0
  28. package/src/cronli5.ts +7 -7
  29. package/src/lang/de/index.ts +329 -288
  30. package/src/lang/en/dialects.ts +1 -1
  31. package/src/lang/en/index.ts +640 -516
  32. package/src/lang/es/index.ts +342 -374
  33. package/src/lang/es/notes.md +1 -1
  34. package/src/lang/fi/dialects.ts +1 -1
  35. package/src/lang/fi/index.ts +367 -372
  36. package/src/lang/fi/notes.md +23 -8
  37. package/src/lang/fi/status.json +1 -1
  38. package/src/lang/zh/index.ts +344 -262
  39. package/src/types.ts +6 -6
  40. package/types/core/analyze.d.ts +4 -4
  41. package/types/core/cadence.d.ts +33 -0
  42. package/types/core/index.d.ts +3 -1
  43. package/types/core/normalize.d.ts +1 -1
  44. package/types/core/parse.d.ts +1 -1
  45. package/types/core/{ir.d.ts → schedule.d.ts} +16 -21
  46. package/types/core/shapes.d.ts +2 -1
  47. package/types/core/specs.d.ts +1 -1
  48. package/types/core/util.d.ts +1 -15
  49. package/types/core/weekday.d.ts +10 -0
  50. package/types/lang/de/index.d.ts +1 -1
  51. package/types/lang/en/dialects.d.ts +1 -1
  52. package/types/lang/en/index.d.ts +1 -1
  53. package/types/lang/es/index.d.ts +1 -1
  54. package/types/lang/fi/dialects.d.ts +1 -1
  55. package/types/lang/fi/index.d.ts +1 -1
  56. package/types/lang/zh/index.d.ts +1 -1
  57. package/types/types.d.ts +5 -5
@@ -1,19 +1,23 @@
1
- // The German language module: renders the analyzed cron pattern (IR) as
1
+ // The German language module: renders the analyzed cron pattern (Schedule) as
2
2
  // German. Anchored to Duden; see notes.md for the decisions.
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
5
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
6
6
  import {
7
- arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
8
- } from '../../core/util.js';
7
+ arithmeticStep, hourListStride, offsetCleanStride,
8
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
9
+ } from '../../core/cadence.js';
10
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
11
+ import {isOpenStep} from '../../core/shapes.js';
12
+ import {toFieldNumber} from '../../core/util.js';
9
13
  import type {Cronli5Options} from '../../types.js';
10
14
  import type {
11
- Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
12
- } from '../../core/ir.js';
15
+ Field, HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode, Segment
16
+ } from '../../core/schedule.js';
13
17
  import {resolveDialect, type GermanStyle} from './dialects.js';
14
18
 
15
19
  type Opts = NormalizedOptions<GermanStyle>;
16
- type Renderer = (ir: IR, plan: PlanNode, opts: Opts) => string;
20
+ type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
17
21
  type StepSegment = Extract<Segment, {kind: 'step'}>;
18
22
 
19
23
  // A time unit: its singular and plural noun, and the gender-agreeing form of
@@ -59,11 +63,6 @@ function withAnchor(clause: string, anchor: string): string {
59
63
  return anchor ? clause + ' ' + anchor : clause;
60
64
  }
61
65
 
62
- // The first segment of a step field, which the plan guarantees is step-kinded.
63
- function stepSegment(segments: Segment[] | null): StepSegment {
64
- return (segments as Segment[])[0] as StepSegment;
65
- }
66
-
67
66
  // A step is "clean" when it starts at 0 and evenly divides its cycle (60 for
68
67
  // minutes/seconds, 24 for hours) — only then does "alle N" describe it; an
69
68
  // uneven step fires at discrete points that must be listed.
@@ -86,22 +85,17 @@ function cleanStep(segment: StepSegment, cycle: number): boolean {
86
85
  function renderStride(stride: Stride): string {
87
86
  const {interval, start, last, cycle, unit, anchor} = stride;
88
87
  const cadence = everyN(interval, unit);
89
- const tiles = cycle % interval === 0;
90
-
91
- if (start === 0 && tiles) {
92
- return cadence;
93
- }
94
88
 
95
89
  // A context that supplies its own trailing scope passes an empty anchor, so
96
90
  // the cadence keeps its endpoints but drops the "jeder Stunde" tail.
97
91
  const tail = anchor ? ' ' + anchor : '';
98
92
 
99
- if (start < interval && tiles) {
100
- return cadence + ' ab ' + unit.singular + ' ' + start + tail;
101
- }
102
-
103
- return cadence + ' von ' + unit.singular + ' ' + start + ' bis ' + last +
104
- tail;
93
+ return chooseStride({start, interval, cycle}, {
94
+ bare: () => cadence,
95
+ offset: () => cadence + ' ab ' + unit.singular + ' ' + start + tail,
96
+ bounded: () =>
97
+ cadence + ' von ' + unit.singular + ' ' + start + ' bis ' + last + tail
98
+ });
105
99
  }
106
100
 
107
101
  // A step *shape* segment as its cadence ("alle 6 Minuten ab Minute 5 jeder
@@ -131,27 +125,11 @@ function stepClause(segment: StepSegment, unit: Unit, anchor: string): string {
131
125
  });
132
126
  }
133
127
 
134
- // The sorted numeric values a field's segments cover, or null if any segment
135
- // is not a discrete single (a range or sub-step is not a plain fire list).
136
- function singleValues(segments: Segment[]): number[] | null {
137
- const values: number[] = [];
138
-
139
- for (const segment of segments) {
140
- if (segment.kind !== 'single') {
141
- return null;
142
- }
143
-
144
- values.push(+segment.value);
145
- }
146
-
147
- return values;
148
- }
149
-
150
128
  // Speak a minute/second field's enumerated fires as a step cadence when they
151
129
  // form an arithmetic progression long enough to beat the list (the core
152
- // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
153
- // the renderer recognizes the progression). Returns null for a non-progression
154
- // or a too-short list, leaving the caller to enumerate.
130
+ // enumerates an offset/uneven step to this fire list; the Schedule is
131
+ // unchanged, so the renderer recognizes the progression). Returns null for a
132
+ // non-progression or a too-short list, leaving the caller to enumerate.
155
133
  function strideFromSegments(
156
134
  segments: Segment[],
157
135
  unit: Unit,
@@ -177,10 +155,6 @@ const weekdayNames = [
177
155
  'freitags', 'samstags'
178
156
  ];
179
157
 
180
- function fieldSegments(ir: IR, field: Field): Segment[] {
181
- return ir.analyses.segments[field] as Segment[];
182
- }
183
-
184
158
  // Expand step segments into their fires as singles so a name list reads flat.
185
159
  function flattenSteps(segments: Segment[]): NameSegment[] {
186
160
  return segments.flatMap(function flat(segment): NameSegment[] {
@@ -216,10 +190,10 @@ function weekdayRange(bounds: [string, string]): string {
216
190
  }
217
191
 
218
192
  // "montags", "montags bis freitags", "montags, mittwochs und freitags".
219
- function weekdayQualifier(ir: IR): string {
193
+ function weekdayQualifier(schedule: Schedule): string {
220
194
  // 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'));
195
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
196
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
223
197
 
224
198
  if (segments.length === 1 && segments[0].kind === 'range') {
225
199
  return weekdayRange(segments[0].bounds);
@@ -313,6 +287,29 @@ function quartzDate(field: string): string | null {
313
287
  return null;
314
288
  }
315
289
 
290
+ // An open interval-2 day-of-month step covers a parity set, so it reads as the
291
+ // parity class ("an jedem ungeraden Tag des Monats") rather than enumerating
292
+ // its 16 fires — the enumeration would bury the union beside the "oder". `*/2`
293
+ // and `1/2` are the odd days, `2/2` the even; any other start enumerates.
294
+ // Mirrors en's odd/even-numbered-day idiom. Null when not such a step.
295
+ function oddEvenDay(dateField: string): string | null {
296
+ if (!isOpenStep(dateField)) {
297
+ return null;
298
+ }
299
+
300
+ const [start, step] = dateField.split('/');
301
+
302
+ if (+step !== 2) {
303
+ return null;
304
+ }
305
+
306
+ if (start === '*' || start === '1') {
307
+ return 'an jedem ungeraden Tag des Monats';
308
+ }
309
+
310
+ return start === '2' ? 'an jedem geraden Tag des Monats' : null;
311
+ }
312
+
316
313
  type Months = GermanStyle['months'];
317
314
 
318
315
  // The month names are dialect-scoped (resolved from `opts.style.months`);
@@ -328,8 +325,8 @@ function monthRange(bounds: [string, string], months: Months): string {
328
325
  }
329
326
 
330
327
  // Bare month names: "Januar", "Januar und Juli", "von Juni bis August".
331
- function monthNamesList(ir: IR, months: Months): string {
332
- return joinList(flattenSteps(fieldSegments(ir, 'month'))
328
+ function monthNamesList(schedule: Schedule, months: Months): string {
329
+ return joinList(flattenSteps(segmentsOf(schedule, 'month'))
333
330
  .map(function name(segment): string {
334
331
  return segment.kind === 'range' ?
335
332
  monthRange(segment.bounds, months) :
@@ -339,19 +336,21 @@ function monthNamesList(ir: IR, months: Months): string {
339
336
 
340
337
  // The month qualifier: "im Januar", "im Januar und Juli", "von Juni bis
341
338
  // August". A lone range carries its own "von … bis"; names take "im".
342
- function monthClause(ir: IR, months: Months): string {
343
- const segments = flattenSteps(fieldSegments(ir, 'month'));
339
+ function monthClause(schedule: Schedule, months: Months): string {
340
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
344
341
 
345
342
  if (segments.length === 1 && segments[0].kind === 'range') {
346
343
  return monthRange(segments[0].bounds, months);
347
344
  }
348
345
 
349
- return 'im ' + monthNamesList(ir, months);
346
+ return 'im ' + monthNamesList(schedule, months);
350
347
  }
351
348
 
352
349
  // The month appended after a weekday: " im Januar" or "".
353
- function monthScope(ir: IR, months: Months): string {
354
- return ir.pattern.month === '*' ? '' : ' ' + monthClause(ir, months);
350
+ function monthScope(schedule: Schedule, months: Months): string {
351
+ return schedule.pattern.month === '*' ?
352
+ '' :
353
+ ' ' + monthClause(schedule, months);
355
354
  }
356
355
 
357
356
  // A day-of-month ordinal: a numeral with a period ("1.").
@@ -366,8 +365,8 @@ function dateRange(bounds: [string, string]): string {
366
365
 
367
366
  // The bare date clause, without a month: "am 1.", "am 1. und 15.", "vom 1.
368
367
  // bis zum 5.", "vom 1. bis zum 5. und am 10.".
369
- function dateClauseBare(ir: IR): string {
370
- const segments = flattenSteps(fieldSegments(ir, 'date'));
368
+ function dateClauseBare(schedule: Schedule): string {
369
+ const segments = flattenSteps(segmentsOf(schedule, 'date'));
371
370
 
372
371
  if (segments.length === 1 && segments[0].kind === 'range') {
373
372
  return dateRange(segments[0].bounds);
@@ -392,19 +391,19 @@ function dateClauseBare(ir: IR): string {
392
391
  // The date qualifier with its month. Month names fold bare onto the date
393
392
  // ("am 1. Januar", "am 1. Januar und Juli"); a month range cannot, so it
394
393
  // trails as a scoped clause after a comma ("am 1., von Juni bis August").
395
- function datePhrase(ir: IR, months: Months): string {
396
- const clause = dateClauseBare(ir);
394
+ function datePhrase(schedule: Schedule, months: Months): string {
395
+ const clause = dateClauseBare(schedule);
397
396
 
398
- if (ir.pattern.month === '*') {
397
+ if (schedule.pattern.month === '*') {
399
398
  return clause;
400
399
  }
401
400
 
402
- const monthRanged = flattenSteps(fieldSegments(ir, 'month'))
401
+ const monthRanged = flattenSteps(segmentsOf(schedule, 'month'))
403
402
  .some((segment) => segment.kind === 'range');
404
403
 
405
404
  return monthRanged ?
406
- clause + ', ' + monthClause(ir, months) :
407
- clause + ' ' + monthNamesList(ir, months);
405
+ clause + ', ' + monthClause(schedule, months) :
406
+ clause + ' ' + monthNamesList(schedule, months);
408
407
  }
409
408
 
410
409
  // A bare clock time: "9" on the hour, "14:30", or "0:00:30" with a second.
@@ -449,8 +448,8 @@ function hourWindow(
449
448
  }
450
449
 
451
450
  // A field's values as strings, a range rendered "a bis b".
452
- function fieldValues(ir: IR, field: Field): string[] {
453
- return flattenSteps(fieldSegments(ir, field)).map(function value(segment) {
451
+ function fieldValues(schedule: Schedule, field: Field): string[] {
452
+ return flattenSteps(segmentsOf(schedule, field)).map(function value(segment) {
454
453
  return segment.kind === 'range' ?
455
454
  segment.bounds[0] + ' bis ' + segment.bounds[1] :
456
455
  String(segment.value);
@@ -459,16 +458,16 @@ function fieldValues(ir: IR, field: Field): string[] {
459
458
 
460
459
  // "in Minute 5", "in den Minuten 5, 10 und 30", "in den Minuten 0 bis 30".
461
460
  function countedPhrase(
462
- ir: IR,
461
+ schedule: Schedule,
463
462
  field: Field,
464
463
  singular: string,
465
464
  plural: string
466
465
  ): string {
467
- if (ir.shapes[field] === 'single') {
468
- return 'in ' + singular + ' ' + ir.pattern[field];
466
+ if (schedule.shapes[field] === 'single') {
467
+ return 'in ' + singular + ' ' + schedule.pattern[field];
469
468
  }
470
469
 
471
- return 'in den ' + plural + ' ' + joinList(fieldValues(ir, field));
470
+ return 'in den ' + plural + ' ' + joinList(fieldValues(schedule, field));
472
471
  }
473
472
 
474
473
  // The minute scope for a seconds clause: "jeder Minute" only when the minute
@@ -477,15 +476,15 @@ function countedPhrase(
477
476
  // clause drops the scope — "jeder Minute" would otherwise contradict the fixed
478
477
  // minute ("in Sekunde 30 jeder Minute, in Minute 30" fires at second 30 of
479
478
  // minute 30, not every minute).
480
- function minuteAnchor(ir: IR): string {
481
- return ir.pattern.minute === '*' ? 'jeder Minute' : '';
479
+ function minuteAnchor(schedule: Schedule): string {
480
+ return schedule.pattern.minute === '*' ? 'jeder Minute' : '';
482
481
  }
483
482
 
484
483
  // The seconds clause: "alle 30 Sekunden" for a step, "in Sekunde 15 jeder
485
484
  // Minute" under a wildcard minute, else the bare "in Sekunde 15" when the
486
485
  // minute is fixed (its own clause names it).
487
- function secondsLead(ir: IR): string {
488
- return secondsClause(ir, minuteAnchor(ir));
486
+ function secondsLead(schedule: Schedule): string {
487
+ return secondsClause(schedule, minuteAnchor(schedule));
489
488
  }
490
489
 
491
490
  // The second clause counted against an arbitrary anchor. The anchor is "jeder
@@ -493,22 +492,24 @@ function secondsLead(ir: IR): string {
493
492
  // minute 0 into the hour and counts the second "jeder Stunde" instead ("in
494
493
  // Sekunde 30 jeder Stunde"), so the minute-0 confinement is stated, not
495
494
  // dropped.
496
- function secondsClause(ir: IR, anchor: string): string {
497
- if (ir.pattern.second === '*') {
495
+ function secondsClause(schedule: Schedule, anchor: string): string {
496
+ if (schedule.pattern.second === '*') {
498
497
  return 'jede Sekunde';
499
498
  }
500
499
 
501
- const segments = ir.analyses.segments.second;
500
+ const segments = schedule.analyses.segments.second;
502
501
 
503
502
  // A step shape speaks its cadence directly; an offset/uneven step the core
504
503
  // enumerated to a list is recognized as a progression. Both fall back to the
505
504
  // counted list (a short or irregular set).
506
- if (ir.shapes.second === 'step') {
507
- return stepClause(stepSegment(segments), UNITS.second, anchor);
505
+ if (schedule.shapes.second === 'step') {
506
+ return stepClause(stepSegment(schedule, 'second'), UNITS.second, anchor);
508
507
  }
509
508
 
510
509
  return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
511
- withAnchor(countedPhrase(ir, 'second', 'Sekunde', 'Sekunden'), anchor);
510
+ withAnchor(
511
+ countedPhrase(schedule, 'second', 'Sekunde', 'Sekunden'), anchor
512
+ );
512
513
  }
513
514
 
514
515
  // A clock time that always shows its minutes: "9:00", "9:30".
@@ -522,8 +523,8 @@ function atHours(hours: number[]): string {
522
523
  }
523
524
 
524
525
  // The discrete hour fires, single and step values flattened: [9, 17, 19, …].
525
- function hourFires(ir: IR): number[] {
526
- return flattenSteps(fieldSegments(ir, 'hour')).map(function fire(segment) {
526
+ function hourFires(schedule: Schedule): number[] {
527
+ return flattenSteps(segmentsOf(schedule, 'hour')).map(function fire(segment) {
527
528
  return segment.kind === 'range' ? +segment.bounds[0] : +segment.value;
528
529
  });
529
530
  }
@@ -545,12 +546,12 @@ function partTime(
545
546
  // The hour segments as parts: a range is a window, a single an "um H Uhr", a
546
547
  // step its fires. `minute`/`second` attach to each.
547
548
  function hourSegmentParts(
548
- ir: IR,
549
+ schedule: Schedule,
549
550
  minute: number,
550
551
  second: number | undefined,
551
552
  sep: string
552
553
  ): string[] {
553
- return fieldSegments(ir, 'hour').map(function part(segment): string {
554
+ return segmentsOf(schedule, 'hour').map(function part(segment): string {
554
555
  if (segment.kind === 'range') {
555
556
  return 'von ' + partTime(+segment.bounds[0], minute, second, sep) +
556
557
  ' bis ' + partTime(+segment.bounds[1], minute, second, sep) + ' Uhr';
@@ -568,14 +569,16 @@ function hourSegmentParts(
568
569
 
569
570
  // Each "during" hour as a full window (H:00–H:59); a range spans one window,
570
571
  // a step its fires.
571
- function duringWindows(ir: IR, times: HourTimesPlan, sep: string): string[] {
572
+ function duringWindows(
573
+ schedule: Schedule, times: HourTimesPlan, sep: string
574
+ ): string[] {
572
575
  if (times.kind === 'fires') {
573
576
  return times.fires.map(function each(hour) {
574
577
  return hourWindow(hour, hour, 59, sep);
575
578
  });
576
579
  }
577
580
 
578
- return fieldSegments(ir, 'hour').flatMap(function part(segment): string[] {
581
+ return segmentsOf(schedule, 'hour').flatMap(function part(segment): string[] {
579
582
  if (segment.kind === 'range') {
580
583
  return [hourWindow(+segment.bounds[0], +segment.bounds[1], 59, sep)];
581
584
  }
@@ -593,8 +596,10 @@ function duringWindows(ir: IR, times: HourTimesPlan, sep: string): string[] {
593
596
  // The "during" hours of a confined cadence: a few hours read as windows ("von
594
597
  // 9 bis 9:59 Uhr und …"); many read better as a compact list ("in den Stunden
595
598
  // von 9, 11, 13, 15 und 17 Uhr") instead of sprawling windows.
596
- function duringHours(ir: IR, times: HourTimesPlan, sep: string): string {
597
- const windows = duringWindows(ir, times, sep);
599
+ function duringHours(
600
+ schedule: Schedule, times: HourTimesPlan, sep: string
601
+ ): string {
602
+ const windows = duringWindows(schedule, times, sep);
598
603
 
599
604
  if (windows.length <= 3 || times.kind !== 'fires') {
600
605
  return joinList(windows);
@@ -625,35 +630,35 @@ function renderEveryHour(): string {
625
630
 
626
631
  // The open-minute seconds clause: "alle 30 Sekunden", "in Sekunde 15 jeder
627
632
  // Minute". Serves standaloneSeconds (step) and secondPastMinute (single).
628
- function renderSeconds(ir: IR): string {
629
- return secondsLead(ir);
633
+ function renderSeconds(schedule: Schedule): string {
634
+ return secondsLead(schedule);
630
635
  }
631
636
 
632
637
  // The minute-past-the-hour clause: "in Minute 5 jeder Stunde", "in den
633
638
  // Minuten 5, 10 und 30 jeder Stunde". An offset/uneven step the core
634
639
  // enumerated to this list reads as a stride cadence when the fires form a
635
640
  // long-enough progression ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde").
636
- function minutePastClause(ir: IR): string {
637
- return strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
641
+ function minutePastClause(schedule: Schedule): string {
642
+ return strideFromSegments(segmentsOf(schedule, 'minute'), UNITS.minute,
638
643
  'jeder Stunde') ??
639
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
644
+ countedPhrase(schedule, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
640
645
  }
641
646
 
642
- function renderMinutePast(ir: IR): string {
643
- return minutePastClause(ir);
647
+ function renderMinutePast(schedule: Schedule): string {
648
+ return minutePastClause(schedule);
644
649
  }
645
650
 
646
651
  // A specific minute and second: "in Minute 0 und Sekunde 30 jeder Stunde".
647
652
  function renderSecondsWithinMinute(
648
- ir: IR,
653
+ schedule: Schedule,
649
654
  plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>
650
655
  ): string {
651
656
  if (plan.singleSecond) {
652
- return 'in Minute ' + ir.pattern.minute + ' und Sekunde ' +
653
- ir.pattern.second + ' jeder Stunde';
657
+ return 'in Minute ' + schedule.pattern.minute + ' und Sekunde ' +
658
+ schedule.pattern.second + ' jeder Stunde';
654
659
  }
655
660
 
656
- return secondsLead(ir) + ', in Minute ' + ir.pattern.minute +
661
+ return secondsLead(schedule) + ', in Minute ' + schedule.pattern.minute +
657
662
  ' jeder Stunde';
658
663
  }
659
664
 
@@ -676,11 +681,11 @@ function wholeHour(hour: number): string {
676
681
  // Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
677
682
  // range the source never stated; a plain range is a real window and keeps it.
678
683
  function renderMinuteSpanInHour(
679
- ir: IR,
684
+ schedule: Schedule,
680
685
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
681
686
  opts: Opts
682
687
  ): string {
683
- if (ir.pattern.minute === '*') {
688
+ if (schedule.pattern.minute === '*') {
684
689
  return 'jede Minute ' + wholeHour(plan.hour);
685
690
  }
686
691
 
@@ -698,21 +703,22 @@ function renderMinuteSpanInHour(
698
703
  // English. Other strides, a restricted hour, and an hour cadence keep the
699
704
  // juxtaposed form.
700
705
  function isEveryOtherMinuteSeconds(
701
- ir: IR,
706
+ schedule: Schedule,
702
707
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
703
708
  ): boolean {
704
709
  if (plan.rest.kind !== 'minuteFrequency' ||
705
- ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
710
+ schedule.shapes.second !== 'wildcard' ||
711
+ schedule.shapes.hour !== 'wildcard') {
706
712
  return false;
707
713
  }
708
714
 
709
- const minuteStep = stepSegment(ir.analyses.segments.minute);
715
+ const minuteStep = stepSegment(schedule, 'minute');
710
716
 
711
717
  return minuteStep.startToken === '*' && minuteStep.interval === 2;
712
718
  }
713
719
 
714
720
  function renderComposeSeconds(
715
- ir: IR,
721
+ schedule: Schedule,
716
722
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
717
723
  opts: Opts
718
724
  ): string {
@@ -722,9 +728,10 @@ function renderComposeSeconds(
722
728
  // clock-time rest would otherwise cross-multiply the hours.
723
729
  if ((plan.rest.kind === 'clockTimes' ||
724
730
  plan.rest.kind === 'compactClockTimes') &&
725
- ir.shapes.minute === 'single') {
726
- const minute = +ir.pattern.minute;
727
- const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
731
+ schedule.shapes.minute === 'single') {
732
+ const minute = +schedule.pattern.minute;
733
+ const cadence = hourCadence(schedule, minute) ??
734
+ hourRangeCadence(schedule, minute);
728
735
 
729
736
  if (cadence !== null) {
730
737
  return cadence;
@@ -736,15 +743,15 @@ function renderComposeSeconds(
736
743
  // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
737
744
  // Bind the seconds into the explicit clock minute in the genitive ("der
738
745
  // Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
739
- if (composeMinuteZero(ir, plan)) {
740
- return secondsLead(ir) + ' ' +
746
+ if (composeMinuteZero(schedule, plan)) {
747
+ return secondsLead(schedule) + ' ' +
741
748
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
742
749
  }
743
750
 
744
751
  // A wildcard second under a minute */2 with a wildcard hour binds in the
745
752
  // genitive ("jede Sekunde jeder zweiten Minute").
746
- if (isEveryOtherMinuteSeconds(ir, plan)) {
747
- return secondsLead(ir) + ' jeder zweiten Minute';
753
+ if (isEveryOtherMinuteSeconds(schedule, plan)) {
754
+ return secondsLead(schedule) + ' jeder zweiten Minute';
748
755
  }
749
756
 
750
757
  // A compact clock-time rest folds a meaningful SINGLE second into its own
@@ -752,17 +759,17 @@ function renderComposeSeconds(
752
759
  // double it. A wildcard or stepped second is not folded there (no
753
760
  // clockSecond), so it still leads its own clause here.
754
761
  const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
755
- ir.analyses.clockSecond;
756
- const lead = restOwnsLead ? '' : secondsLead(ir) + ', ';
762
+ schedule.analyses.clockSecond;
763
+ const lead = restOwnsLead ? '' : secondsLead(schedule) + ', ';
757
764
 
758
- return lead + render(ir, plan.rest, opts);
765
+ return lead + render(schedule, plan.rest, opts);
759
766
  }
760
767
 
761
768
  // True when a compose-seconds plan is a sub-minute second over a minute-0
762
769
  // clock-time rest — the case that reads as the bare hour and so must surface
763
770
  // the pinned clock minute.
764
771
  function composeMinuteZero(
765
- ir: IR,
772
+ schedule: Schedule,
766
773
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
767
774
  ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
768
775
  {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
@@ -789,25 +796,25 @@ function clockMinuteGenitive(
789
796
  // A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
790
797
  // 17 Uhr".
791
798
  function renderMinutesAcrossHours(
792
- ir: IR,
799
+ schedule: Schedule,
793
800
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
794
801
  opts: Opts
795
802
  ): string {
796
803
  const sep = opts.style.sep;
797
804
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence,
798
805
  // not a wall of hour columns.
799
- const cadence = unevenHourCadence(ir);
806
+ const cadence = unevenHourCadence(schedule);
800
807
 
801
808
  // The wildcard form means every minute *during* each hour: render windows.
802
809
  if (plan.form === 'wildcard') {
803
810
  return cadence ?
804
811
  'jede Minute, ' + cadence :
805
- 'jede Minute ' + duringHours(ir, plan.times, sep);
812
+ 'jede Minute ' + duringHours(schedule, plan.times, sep);
806
813
  }
807
814
 
808
815
  const minuteLead =
809
- strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
810
- countedPhrase(ir, 'minute', 'Minute', 'Minuten');
816
+ strideFromSegments(segmentsOf(schedule, 'minute'), UNITS.minute, '') ??
817
+ countedPhrase(schedule, 'minute', 'Minute', 'Minuten');
811
818
 
812
819
  if (cadence !== null) {
813
820
  return minuteLead + ', ' + cadence;
@@ -815,7 +822,7 @@ function renderMinutesAcrossHours(
815
822
 
816
823
  const hours = plan.times.kind === 'fires' ?
817
824
  atHours(plan.times.fires) :
818
- joinList(hourSegmentParts(ir, 0, 0, sep));
825
+ joinList(hourSegmentParts(schedule, 0, 0, sep));
819
826
 
820
827
  return minuteLead + ', ' + hours;
821
828
  }
@@ -825,31 +832,33 @@ function renderMinutesAcrossHours(
825
832
  // Minute in jeder zweiten Stunde"); a range or list leads with its minutes and
826
833
  // trails the same cadence ("in den Minuten 0 bis 30, in jeder zweiten Stunde").
827
834
  function renderMinuteSpanAcrossHourStep(
828
- ir: IR,
835
+ schedule: Schedule,
829
836
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
830
837
  ): string {
831
838
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
832
839
  // offset-clean stride keeps its "in jeder N-ten Stunde" confinement.
833
- const cadence = unevenHourCadence(ir);
840
+ const cadence = unevenHourCadence(schedule);
834
841
 
835
842
  // A wildcard minute over a stepped hour is reached only for a clean stride (a
836
843
  // bounded or uneven step routes through minutesAcrossHours instead).
837
844
  if (plan.form === 'wildcard') {
838
845
  return 'jede Minute ' +
839
- everyNthHour(stepSegment(ir.analyses.segments.hour));
846
+ everyNthHour(stepSegment(schedule, 'hour'));
840
847
  }
841
848
 
842
849
  // The minute (range or list) leads; the hour trails. A clean stride confines
843
850
  // to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
844
851
  // minute-step compositions use, never a juxtaposed second frequency. A
845
852
  // bounded or uneven stride trails its endpoint-pinning cadence instead.
846
- const segment = stepSegment(ir.analyses.segments.hour);
853
+ const segment = stepSegment(schedule, 'hour');
847
854
  const hours = cadence ?? (confinedHourStride(segment) ?
848
855
  everyNthHour(segment) :
849
856
  atHours(segment.fires));
850
857
 
851
- return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
852
- countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
858
+ return (
859
+ strideFromSegments(segmentsOf(schedule, 'minute'), UNITS.minute, '') ??
860
+ countedPhrase(schedule, 'minute', 'Minute', 'Minuten')
861
+ ) + ', ' + hours;
853
862
  }
854
863
 
855
864
  // Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
@@ -858,7 +867,7 @@ function renderMinuteSpanAcrossHourStep(
858
867
  // or list is a daily enumeration of its times ("täglich um 0:05, 2:05, …"),
859
868
  // never hourly.
860
869
  function renderCompactClockTimes(
861
- ir: IR,
870
+ schedule: Schedule,
862
871
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
863
872
  opts: Opts
864
873
  ): string {
@@ -868,46 +877,48 @@ function renderCompactClockTimes(
868
877
  // An hour step or range (or arithmetic-progression hour list) under the
869
878
  // single pinned minute reads as a cadence or window, not a wall of clock
870
879
  // times. (Returns null for an irregular list, which keeps folding below.)
871
- const cadence = hourCadence(ir, plan.minute) ??
872
- hourRangeCadence(ir, plan.minute);
880
+ const cadence = hourCadence(schedule, plan.minute) ??
881
+ hourRangeCadence(schedule, plan.minute);
873
882
 
874
883
  if (cadence !== null) {
875
884
  return cadence;
876
885
  }
877
886
 
878
- const hourly = fieldSegments(ir, 'hour')
887
+ const hourly = segmentsOf(schedule, 'hour')
879
888
  .some((segment) => segment.kind === 'range');
880
889
 
881
890
  return (hourly ? 'stündlich ' : 'täglich ') +
882
- joinList(hourSegmentParts(ir, plan.minute, ir.analyses.clockSecond, sep));
891
+ joinList(hourSegmentParts(
892
+ schedule, plan.minute, schedule.analyses.clockSecond, sep
893
+ ));
883
894
  }
884
895
 
885
896
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence; else
886
897
  // 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)));
898
+ const hours = unevenHourCadence(schedule) ??
899
+ (segmentsOf(schedule, 'hour').some((segment) => segment.kind === 'range') ?
900
+ joinList(hourSegmentParts(schedule, 0, 0, sep)) :
901
+ atHours(hourFires(schedule)));
891
902
 
892
903
  // A folded second has no single clock time to attach to here, so it leads
893
904
  // as its own clause ("in Sekunde 30, ..."). It is the bare second (not
894
905
  // secondsLead's "… jeder Minute") because the minutes are constrained.
895
- const lead = ir.analyses.clockSecond ?
896
- countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
906
+ const lead = schedule.analyses.clockSecond ?
907
+ countedPhrase(schedule, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
897
908
 
898
909
  return lead +
899
- (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
900
- countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
910
+ (strideFromSegments(segmentsOf(schedule, 'minute'), UNITS.minute, '') ??
911
+ countedPhrase(schedule, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
901
912
  }
902
913
 
903
914
  // A repeating minute step, optionally within an hour window: "alle 5
904
915
  // Minuten", "alle 15 Minuten von 9 bis 17:45 Uhr".
905
916
  function renderMinuteFrequency(
906
- ir: IR,
917
+ schedule: Schedule,
907
918
  plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
908
919
  opts: Opts
909
920
  ): string {
910
- const segment = stepSegment(ir.analyses.segments.minute);
921
+ const segment = stepSegment(schedule, 'minute');
911
922
  const sep = opts.style.sep;
912
923
  const clean = cleanStep(segment, 60);
913
924
 
@@ -930,18 +941,18 @@ function renderMinuteFrequency(
930
941
  // A bounded or uneven hour stride confines the minute cadence to its own
931
942
  // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
932
943
  // 20 Uhr").
933
- const cadence = unevenHourCadence(ir);
944
+ const cadence = unevenHourCadence(schedule);
934
945
 
935
946
  return cadence ?
936
947
  base + ', ' + cadence :
937
- base + ' ' + duringHours(ir, plan.hours.times, sep);
948
+ base + ' ' + duringHours(schedule, plan.hours.times, sep);
938
949
  }
939
950
 
940
951
  if (plan.hours.kind === 'step') {
941
952
  // The plan carries a step only for a clean step (dividing the day):
942
953
  // confine the cadence to every Nth hour ("in jeder zweiten Stunde").
943
954
  return base + ' ' +
944
- everyNthHour(stepSegment(ir.analyses.segments.hour));
955
+ everyNthHour(stepSegment(schedule, 'hour'));
945
956
  }
946
957
 
947
958
  return base;
@@ -954,14 +965,14 @@ function renderMinuteFrequency(
954
965
  // bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
955
966
  // An explicitly bounded step (`a-b/n`) keeps its enumerated hours, matching
956
967
  // en/fi/zh; only an OPEN step (`m/n`) reads as the wrapping cadence.
957
- function hourStepPhrase(ir: IR): string {
958
- const cadence = unevenHourCadence(ir);
968
+ function hourStepPhrase(schedule: Schedule): string {
969
+ const cadence = unevenHourCadence(schedule);
959
970
 
960
971
  if (cadence !== null) {
961
972
  return cadence;
962
973
  }
963
974
 
964
- const segment = stepSegment(ir.analyses.segments.hour);
975
+ const segment = stepSegment(schedule, 'hour');
965
976
 
966
977
  if (cleanStep(segment, 24)) {
967
978
  return everyN(segment.interval, UNITS.hour);
@@ -971,7 +982,7 @@ function hourStepPhrase(ir: IR): string {
971
982
  // endpoint: name only its start, the cadence en/fi/zh and the compose paths
972
983
  // already speak — never the enumerated hour list. A bounded `a-b/n` keeps its
973
984
  // explicit hours.
974
- const stride = openOffsetCleanStride(ir, segment);
985
+ const stride = openOffsetCleanStride(schedule, segment);
975
986
 
976
987
  return stride ? hourStrideCadence(stride) : atHours(segment.fires);
977
988
  }
@@ -982,13 +993,13 @@ function hourStepPhrase(ir: IR): string {
982
993
  // (`a-b/n`, startToken carries a `-`) is excluded so it keeps its enumerated
983
994
  // hours, matching en/fi/zh.
984
995
  function openOffsetCleanStride(
985
- ir: IR, segment: StepSegment
996
+ schedule: Schedule, segment: StepSegment
986
997
  ): {start: number; interval: number; last: number} | null {
987
998
  if (segment.startToken.indexOf('-') !== -1) {
988
999
  return null;
989
1000
  }
990
1001
 
991
- const stride = hourStride(ir);
1002
+ const stride = hourStride(schedule);
992
1003
 
993
1004
  return stride && offsetCleanStride(stride) ? stride : null;
994
1005
  }
@@ -1007,73 +1018,24 @@ function hourStrideCadence(
1007
1018
  ): string {
1008
1019
  const {start, interval, last} = stride;
1009
1020
  const cadence = everyN(interval, UNITS.hour);
1010
- const tiles = 24 % interval === 0;
1011
-
1012
- if (start === 0 && tiles) {
1013
- return cadence;
1014
- }
1015
-
1016
- if (start < interval && tiles) {
1017
- return cadence + ' ab ' + start + ' Uhr';
1018
- }
1019
-
1020
- return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
1021
- }
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
1021
 
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;
1022
+ return chooseStride({start, interval, cycle: 24}, {
1023
+ bare: () => cadence,
1024
+ offset: () => cadence + ' ab ' + start + ' Uhr',
1025
+ bounded: () => cadence + ' von ' + start + ' bis ' + last + ' Uhr'
1026
+ });
1066
1027
  }
1067
1028
 
1068
1029
  // The hour field's stride, or null when the hour is not a cadence: a step
1069
1030
  // segment yields its {start, interval, last} directly; an all-single hour list
1070
1031
  // 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.
1032
+ // like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
1033
+ // recognizes the stride and speaks it as a cadence, not the clock-time
1034
+ // cross-product.
1073
1035
  function hourStride(
1074
- ir: IR
1036
+ schedule: Schedule
1075
1037
  ): {start: number; interval: number; last: number} | null {
1076
- const segments = fieldSegments(ir, 'hour');
1038
+ const segments = segmentsOf(schedule, 'hour');
1077
1039
 
1078
1040
  // A wildcard hour carries no segments (no discrete hours to stride over).
1079
1041
  if (!segments) {
@@ -1109,8 +1071,8 @@ function hourStride(
1109
1071
  // ("…, alle 5 Stunden von 0 bis 20 Uhr") than as a wall of clock times. An
1110
1072
  // offset-clean stride keeps its existing confinement form, so only the
1111
1073
  // endpoint-bearing case routes here.
1112
- function unevenHourCadence(ir: IR): string | null {
1113
- const stride = hourStride(ir);
1074
+ function unevenHourCadence(schedule: Schedule): string | null {
1075
+ const stride = hourStride(schedule);
1114
1076
 
1115
1077
  if (!stride || offsetCleanStride(stride)) {
1116
1078
  return null;
@@ -1122,8 +1084,8 @@ function unevenHourCadence(ir: IR): string | null {
1122
1084
  // The second's status against a pinned minute: a wildcard or sub-minute step
1123
1085
  // fills the minute (a "für eine Minute" frame at minute 0); a single 0 is just
1124
1086
  // the top of the minute (no clause); anything else needs its own clause.
1125
- function subMinuteSecond(ir: IR): boolean {
1126
- return ir.pattern.second === '*' || ir.shapes.second === 'step';
1087
+ function subMinuteSecond(schedule: Schedule): boolean {
1088
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1127
1089
  }
1128
1090
 
1129
1091
  // The lead clause for an hour-cadence rendering: the second and the pinned
@@ -1133,24 +1095,26 @@ function subMinuteSecond(ir: IR): boolean {
1133
1095
  // Minute" frame (the whole minute-0 window). A non-zero minute is a real clock
1134
1096
  // minute: the second leads with its own clause (if any), then the minute reads
1135
1097
  // "in Minute M".
1136
- function hourCadenceLead(ir: IR, minute: number): string {
1098
+ function hourCadenceLead(schedule: Schedule, minute: number): string {
1137
1099
  if (minute === 0) {
1138
- if (subMinuteSecond(ir)) {
1139
- return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute');
1100
+ if (subMinuteSecond(schedule)) {
1101
+ return withAnchor(
1102
+ secondsClause(schedule, minuteAnchor(schedule)), 'für eine Minute'
1103
+ );
1140
1104
  }
1141
1105
 
1142
- return secondsClause(ir, 'jeder Stunde');
1106
+ return secondsClause(schedule, 'jeder Stunde');
1143
1107
  }
1144
1108
 
1145
1109
  const minutePhrase = 'in Minute ' + minute;
1146
1110
 
1147
1111
  // A single 0 second is just the top of the minute, so the minute leads
1148
1112
  // alone; any other second prefixes its own clause.
1149
- if (ir.pattern.second === '0') {
1113
+ if (schedule.pattern.second === '0') {
1150
1114
  return minutePhrase;
1151
1115
  }
1152
1116
 
1153
- return secondsClause(ir, minuteAnchor(ir)) + ', ' + minutePhrase;
1117
+ return secondsClause(schedule, minuteAnchor(schedule)) + ', ' + minutePhrase;
1154
1118
  }
1155
1119
 
1156
1120
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1162,9 +1126,9 @@ function hourCadenceLead(ir: IR, minute: number): string {
1162
1126
  // clock time three digit-groups, so any stride is worth compacting; otherwise
1163
1127
  // the stride must exceed the clock-time cap, the same point at which the core
1164
1128
  // itself stops enumerating. The renderer returns the bare clause; the day
1165
- // frame is composed in `describe`. Renderer-only; the IR is unchanged.
1166
- function hourCadence(ir: IR, minute: number): string | null {
1167
- const stride = hourStride(ir);
1129
+ // frame is composed in `describe`. Renderer-only; the Schedule is unchanged.
1130
+ function hourCadence(schedule: Schedule, minute: number): string | null {
1131
+ const stride = hourStride(schedule);
1168
1132
 
1169
1133
  if (!stride) {
1170
1134
  return null;
@@ -1177,7 +1141,7 @@ function hourCadence(ir: IR, minute: number): string | null {
1177
1141
  // or "ab" form is no shorter than the list. A bounded or uneven stride has no
1178
1142
  // clean wrap, so its endpoint-pinning cadence ("alle 5 Stunden von 0 bis 20
1179
1143
  // Uhr") reads better however short.
1180
- if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1144
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1181
1145
  offsetCleanStride(stride)) {
1182
1146
  return null;
1183
1147
  }
@@ -1186,46 +1150,47 @@ function hourCadence(ir: IR, minute: number): string | null {
1186
1150
  // stride is a confinement, not a juxtaposed cadence: it reads "für eine
1187
1151
  // Minute in jeder zweiten Stunde", reusing the every-Nth-hour idiom so the
1188
1152
  // minute-0 window is never heard as the bare hour cadence.
1189
- const segment = fieldSegments(ir, 'hour')[0];
1190
- const confined = minute === 0 && subMinuteSecond(ir) &&
1191
- fieldSegments(ir, 'hour').length === 1 && segment.kind === 'step' &&
1153
+ const segment = segmentsOf(schedule, 'hour')[0];
1154
+ const confined = minute === 0 && subMinuteSecond(schedule) &&
1155
+ segmentsOf(schedule, 'hour').length === 1 && segment.kind === 'step' &&
1192
1156
  confinedHourStride(segment);
1193
1157
 
1194
1158
  if (confined) {
1195
- return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute') +
1196
- ' ' + everyNthHour(segment);
1159
+ return withAnchor(
1160
+ secondsClause(schedule, minuteAnchor(schedule)), 'für eine Minute'
1161
+ ) + ' ' + everyNthHour(segment);
1197
1162
  }
1198
1163
 
1199
1164
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1200
1165
  // lead clause to fold in, so the bounded cadence stands on its own ("alle 5
1201
1166
  // Stunden von 0 bis 20 Uhr").
1202
- if (minute === 0 && ir.pattern.second === '0') {
1167
+ if (minute === 0 && schedule.pattern.second === '0') {
1203
1168
  return hourStrideCadence(stride);
1204
1169
  }
1205
1170
 
1206
- return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
1171
+ return hourCadenceLead(schedule, minute) + ', ' + hourStrideCadence(stride);
1207
1172
  }
1208
1173
 
1209
1174
  // Whether an hour cadence or hour-range window applies to a plan with a single
1210
1175
  // pinned minute — the signal that the clause is a cadence/window, not a daily
1211
1176
  // clock-time list, so the "täglich" frame must not be added.
1212
- function hourCadenceApplies(ir: IR): boolean {
1213
- if (ir.shapes.minute !== 'single') {
1177
+ function hourCadenceApplies(schedule: Schedule): boolean {
1178
+ if (schedule.shapes.minute !== 'single') {
1214
1179
  return false;
1215
1180
  }
1216
1181
 
1217
- const minute = +ir.pattern.minute;
1182
+ const minute = +schedule.pattern.minute;
1218
1183
 
1219
- return hourCadence(ir, minute) !== null ||
1220
- hourRangeCadence(ir, minute) !== null;
1184
+ return hourCadence(schedule, minute) !== null ||
1185
+ hourRangeCadence(schedule, minute) !== null;
1221
1186
  }
1222
1187
 
1223
1188
  // Whether the hour field is a range — or a list whose segments include a
1224
1189
  // range — and so forms a window rather than a cross-product of clock times.
1225
1190
  // A pure single-value list (9,17) has no range to span and still enumerates;
1226
1191
  // a step is handled by hourStride/hourCadence.
1227
- function hasHourWindow(ir: IR): boolean {
1228
- const segments = fieldSegments(ir, 'hour');
1192
+ function hasHourWindow(schedule: Schedule): boolean {
1193
+ const segments = segmentsOf(schedule, 'hour');
1229
1194
 
1230
1195
  return !!segments && segments.some(function range(segment) {
1231
1196
  return segment.kind === 'range';
@@ -1240,13 +1205,15 @@ function hasHourWindow(ir: IR): boolean {
1240
1205
  // suppressed by hourCadenceApplies). Returns null when the hour has no range,
1241
1206
  // when the minute is non-zero (a real clock minute the existing window form
1242
1207
  // 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') {
1208
+ // the Schedule is unchanged.
1209
+ function hourRangeCadence(schedule: Schedule, minute: number): string | null {
1210
+ if (minute !== 0 || !hasHourWindow(schedule) ||
1211
+ schedule.pattern.second === '0') {
1246
1212
  return null;
1247
1213
  }
1248
1214
 
1249
- return hourCadenceLead(ir, minute) + ', ' + hourRangeWindowTail(ir);
1215
+ return hourCadenceLead(schedule, minute) + ', ' +
1216
+ hourRangeWindowTail(schedule);
1250
1217
  }
1251
1218
 
1252
1219
  // The hour-range window as a cadence tail at the top of each hour: each range
@@ -1254,15 +1221,15 @@ function hourRangeCadence(ir: IR, minute: number): string | null {
1254
1221
  // — the same parts the bare "stündlich von 9 bis 17 Uhr" window forms, minus
1255
1222
  // the "stündlich" prefix the lead replaces. The minute has folded into the
1256
1223
  // lead, so the parts close on the top of their final hour.
1257
- function hourRangeWindowTail(ir: IR): string {
1224
+ function hourRangeWindowTail(schedule: Schedule): string {
1258
1225
  // Minute 0 with a falsy second renders each part as a bare hour ("von 9 bis
1259
1226
  // 17 Uhr", "um 22 Uhr"); the separator is unused in that path.
1260
- return joinList(hourSegmentParts(ir, 0, 0, ':'));
1227
+ return joinList(hourSegmentParts(schedule, 0, 0, ':'));
1261
1228
  }
1262
1229
 
1263
1230
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
1264
1231
  function renderHourRange(
1265
- ir: IR,
1232
+ schedule: Schedule,
1266
1233
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
1267
1234
  opts: Opts
1268
1235
  ): string {
@@ -1279,7 +1246,7 @@ function renderHourRange(
1279
1246
  return 'jede Minute ' + window;
1280
1247
  }
1281
1248
 
1282
- if (plan.minuteForm === 'lead' && ir.pattern.minute === '0') {
1249
+ if (plan.minuteForm === 'lead' && schedule.pattern.minute === '0') {
1283
1250
  return 'stündlich ' + window;
1284
1251
  }
1285
1252
 
@@ -1288,24 +1255,25 @@ function renderHourRange(
1288
1255
  // bounded cadence ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde") instead
1289
1256
  // of the wall of fires; an irregular list or a single minute keeps the
1290
1257
  // counted form.
1291
- return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
1258
+ return (strideFromSegments(segmentsOf(schedule, 'minute'), UNITS.minute,
1292
1259
  'jeder Stunde') ??
1293
- countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
1260
+ countedPhrase(schedule, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
1294
1261
  ', ' + window;
1295
1262
  }
1296
1263
 
1297
1264
  // One or more clock times: "um 9 Uhr", "um 14:30 Uhr", "um 9 und 17 Uhr".
1298
1265
  function renderClockTimes(
1299
- ir: IR,
1266
+ schedule: Schedule,
1300
1267
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
1301
1268
  opts: Opts
1302
1269
  ): string {
1303
1270
  // An hour step or range (or arithmetic-progression hour list) under a single
1304
1271
  // pinned minute reads as a cadence or window rather than a cross-product of
1305
1272
  // clock times.
1306
- if (ir.shapes.minute === 'single') {
1307
- const minute = +ir.pattern.minute;
1308
- const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
1273
+ if (schedule.shapes.minute === 'single') {
1274
+ const minute = +schedule.pattern.minute;
1275
+ const cadence = hourCadence(schedule, minute) ??
1276
+ hourRangeCadence(schedule, minute);
1309
1277
 
1310
1278
  if (cadence !== null) {
1311
1279
  return cadence;
@@ -1336,33 +1304,100 @@ const renderers = {
1336
1304
  standaloneSeconds: renderSeconds
1337
1305
  };
1338
1306
 
1339
- // The weekday/day/month frame. Date and weekday together are cron's OR case,
1340
- // not yet built.
1341
- function qualifier(ir: IR, months: Months): string {
1342
- const {date, month, weekday} = ir.pattern;
1307
+ // True when both the day-of-month and the weekday are restricted: cron fires on
1308
+ // the UNION of the two sets ("am 1. oder sonntags"). The month, if any, scopes
1309
+ // the WHOLE union and so leads the description (see `dayUnionMonthLead`) rather
1310
+ // than trailing one half, where it would read as scoping only that half.
1311
+ function isDayUnion(schedule: Schedule): boolean {
1312
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
1313
+ }
1343
1314
 
1344
- // Date and weekday together are cron's OR: "am 31. oder freitags". Either
1345
- // side may itself be a Quartz form.
1346
- if (date !== '*' && weekday !== '*') {
1347
- const datePart = quartzDate(date) || dateClauseBare(ir);
1348
- const weekdayPart = quartzWeekday(weekday) || weekdayQualifier(ir);
1315
+ // The leading "im Januar " scope for a day union (empty when the month is a
1316
+ // wildcard). The month brackets both or-branches, so it precedes the whole
1317
+ // description; the union clause itself then carries no trailing month.
1318
+ function dayUnionMonthLead(schedule: Schedule, months: Months): string {
1319
+ return schedule.pattern.month === '*' ?
1320
+ '' :
1321
+ monthClause(schedule, months) + ' ';
1322
+ }
1323
+
1324
+ // The day-of-month half of a union as a predicate. A Quartz date is its
1325
+ // definite phrase; an open `*/2`-style step is the parity class ("an jedem
1326
+ // ungeraden Tag des Monats"), never a 16-date enumeration that would bury the
1327
+ // union; otherwise the plain date clause ("am 1.", "vom 1. bis zum 15.").
1328
+ function dayUnionDate(schedule: Schedule): string {
1329
+ return quartzDate(schedule.pattern.date) ||
1330
+ oddEvenDay(schedule.pattern.date) ||
1331
+ dateClauseBare(schedule);
1332
+ }
1349
1333
 
1350
- return datePart + ' oder ' + weekdayPart + monthScope(ir, months);
1334
+ // The day-of-week half of a union as a predicate. A Quartz weekday is its
1335
+ // definite phrase; the Monday-through-Friday range reads as the weekday class
1336
+ // ("an einem Wochentag (Mo–Fr)"), parallel to the date predicate beside it;
1337
+ // otherwise the adverbial weekday list ("freitags", "montags und mittwochs").
1338
+ function dayUnionWeekday(schedule: Schedule): string {
1339
+ const weekday = schedule.pattern.weekday;
1340
+ const quartz = quartzWeekday(weekday);
1341
+
1342
+ if (quartz) {
1343
+ return quartz;
1344
+ }
1345
+
1346
+ const segments = segmentsOf(schedule, 'weekday');
1347
+
1348
+ if (segments.length === 1 && segments[0].kind === 'range' &&
1349
+ segments[0].bounds[0] === '1' && segments[0].bounds[1] === '5') {
1350
+ return 'an einem Wochentag (Mo–Fr)';
1351
+ }
1352
+
1353
+ return weekdayQualifier(schedule);
1354
+ }
1355
+
1356
+ // An open day-of-month step (`*/n`/`a/n`) as a cadence, not its 16-date
1357
+ // enumeration. Interval 2 reads as the parity-neutral cadence ("jeden zweiten
1358
+ // Tag des Monats") in the standalone case (the OR-union prefers the parity
1359
+ // idiom); other open steps fall back to the enumerated date clause. Null when
1360
+ // the date is not an open step.
1361
+ function dateStepCadence(schedule: Schedule): string | null {
1362
+ const date = schedule.pattern.date;
1363
+
1364
+ if (!isOpenStep(date)) {
1365
+ return null;
1366
+ }
1367
+
1368
+ const [start, step] = date.split('/');
1369
+
1370
+ return (start === '*' || start === '1') && +step === 2 ?
1371
+ 'jeden zweiten Tag des Monats' :
1372
+ null;
1373
+ }
1374
+
1375
+ // The weekday/day/month frame. Date and weekday together are cron's OR case.
1376
+ function qualifier(schedule: Schedule, months: Months): string {
1377
+ const {date, month, weekday} = schedule.pattern;
1378
+
1379
+ // Date and weekday together are cron's OR: "am 31. oder freitags". Either
1380
+ // side may itself be a Quartz or parity form. The month leads the whole
1381
+ // union (handled in `describe`), so the union clause carries none here.
1382
+ if (isDayUnion(schedule)) {
1383
+ return dayUnionDate(schedule) + ' oder ' + dayUnionWeekday(schedule);
1351
1384
  }
1352
1385
 
1353
1386
  if (weekday !== '*') {
1354
- return (quartzWeekday(weekday) || weekdayQualifier(ir)) +
1355
- monthScope(ir, months);
1387
+ return (quartzWeekday(weekday) || weekdayQualifier(schedule)) +
1388
+ monthScope(schedule, months);
1356
1389
  }
1357
1390
 
1358
1391
  if (date !== '*') {
1359
- const quartz = quartzDate(date);
1392
+ const quartz = quartzDate(date) || dateStepCadence(schedule);
1360
1393
 
1361
- return quartz ? quartz + monthScope(ir, months) : datePhrase(ir, months);
1394
+ return quartz ?
1395
+ quartz + monthScope(schedule, months) :
1396
+ datePhrase(schedule, months);
1362
1397
  }
1363
1398
 
1364
1399
  if (month !== '*') {
1365
- return monthClause(ir, months);
1400
+ return monthClause(schedule, months);
1366
1401
  }
1367
1402
 
1368
1403
  return '';
@@ -1376,15 +1411,15 @@ const LEADING_PLANS = new Set(['clockTimes']);
1376
1411
 
1377
1412
  // True when the leading qualifier should precede the clause: a clock-time
1378
1413
  // plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
1379
- function leadsQualifier(ir: IR): boolean {
1380
- return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
1414
+ function leadsQualifier(schedule: Schedule): boolean {
1415
+ return LEADING_PLANS.has(schedule.plan.kind) || isComposeMinuteZero(schedule);
1381
1416
  }
1382
1417
 
1383
1418
  // Whether the planned clause is the minute-0 compose-seconds confinement
1384
1419
  // (a sub-minute second over a minute-0 clock-time rest).
1385
- function isComposeMinuteZero(ir: IR): boolean {
1386
- return ir.plan.kind === 'composeSeconds' &&
1387
- composeMinuteZero(ir, ir.plan);
1420
+ function isComposeMinuteZero(schedule: Schedule): boolean {
1421
+ return schedule.plan.kind === 'composeSeconds' &&
1422
+ composeMinuteZero(schedule, schedule.plan);
1388
1423
  }
1389
1424
 
1390
1425
  // True when the clause is a bare daily clock-time list and so needs the
@@ -1392,19 +1427,19 @@ function isComposeMinuteZero(ir: IR): boolean {
1392
1427
  // minute-0 compose-seconds clause (a recurring clock minute), and an uneven
1393
1428
  // hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
1394
1429
  // N Stunden"). A frequency clause already implies recurrence.
1395
- function needsDailyFrame(ir: IR): boolean {
1430
+ function needsDailyFrame(schedule: Schedule): boolean {
1396
1431
  // An hour cadence is a sub-daily frequency, not a daily clock-time list, so
1397
1432
  // it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
1398
1433
  // 2 Stunden").
1399
- if (hourCadenceApplies(ir)) {
1434
+ if (hourCadenceApplies(schedule)) {
1400
1435
  return false;
1401
1436
  }
1402
1437
 
1403
- if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
1438
+ if (schedule.plan.kind === 'clockTimes' || isComposeMinuteZero(schedule)) {
1404
1439
  return true;
1405
1440
  }
1406
1441
 
1407
- if (ir.plan.kind !== 'hourStep') {
1442
+ if (schedule.plan.kind !== 'hourStep') {
1408
1443
  return false;
1409
1444
  }
1410
1445
 
@@ -1412,14 +1447,14 @@ function needsDailyFrame(ir: IR): boolean {
1412
1447
  // frequency, not a daily clock-time list, so it takes no "täglich" frame —
1413
1448
  // only a bounded `a-b/n` step that enumerates its hours ("um 1, 3, … Uhr")
1414
1449
  // needs the recurring frame.
1415
- const segment = stepSegment(ir.analyses.segments.hour);
1450
+ const segment = stepSegment(schedule, 'hour');
1416
1451
 
1417
- return !cleanStep(segment, 24) && !openOffsetCleanStride(ir, segment);
1452
+ return !cleanStep(segment, 24) && !openOffsetCleanStride(schedule, segment);
1418
1453
  }
1419
1454
 
1420
- function render(ir: IR, plan: PlanNode, opts: Opts): string {
1455
+ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
1421
1456
  return (renderers[plan.kind as keyof typeof renderers] as Renderer)(
1422
- ir, plan, opts);
1457
+ schedule, plan, opts);
1423
1458
  }
1424
1459
 
1425
1460
  function normalizeOptions(options?: Cronli5Options): Opts {
@@ -1438,8 +1473,8 @@ function normalizeOptions(options?: Cronli5Options): Opts {
1438
1473
 
1439
1474
  // Append the year frame: "im Jahr 2026", "in den Jahren 2025 und 2027", "von
1440
1475
  // 2025 bis 2027".
1441
- function applyYear(description: string, ir: IR): string {
1442
- const year = ir.pattern.year;
1476
+ function applyYear(description: string, schedule: Schedule): string {
1477
+ const year = schedule.pattern.year;
1443
1478
 
1444
1479
  if (year === '*') {
1445
1480
  return description;
@@ -1458,21 +1493,27 @@ function applyYear(description: string, ir: IR): string {
1458
1493
  return description + ' im Jahr ' + year;
1459
1494
  }
1460
1495
 
1461
- function describe(ir: IR, opts: Opts): string {
1462
- const core = render(ir, ir.plan, opts);
1463
- const qual = qualifier(ir, opts.style.months);
1496
+ function describe(schedule: Schedule, opts: Opts): string {
1497
+ const core = render(schedule, schedule.plan, opts);
1498
+ const qual = qualifier(schedule, opts.style.months);
1464
1499
  let base = core;
1465
1500
 
1466
1501
  if (qual) {
1467
- base = leadsQualifier(ir) ?
1502
+ base = leadsQualifier(schedule) ?
1468
1503
  qual + ' ' + core :
1469
1504
  core + ' ' + qual;
1470
1505
  }
1471
- else if (needsDailyFrame(ir)) {
1506
+ else if (needsDailyFrame(schedule)) {
1472
1507
  base = 'täglich ' + core;
1473
1508
  }
1474
1509
 
1475
- return applyYear(base, ir);
1510
+ // A day union's month brackets both or-branches, so it leads the whole
1511
+ // description rather than trailing one half (the qualifier left it off).
1512
+ if (isDayUnion(schedule)) {
1513
+ base = dayUnionMonthLead(schedule, opts.style.months) + base;
1514
+ }
1515
+
1516
+ return applyYear(base, schedule);
1476
1517
  }
1477
1518
 
1478
1519
  const de: Language<GermanStyle> = {