cronli5 0.2.1 → 0.3.4

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