cronli5 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  // the core stays semantic, and this module's only input is the IR.
4
4
  // See docs/i18n-design.md.
5
5
 
6
- import {arithmeticStep} from '../../core/util.js';
6
+ import {arithmeticStep, orderWeekdaysForDisplay} from '../../core/util.js';
7
7
  import {maxClockTimes} from '../../core/specs.js';
8
8
  import {clockDigits, numeral} from '../../core/format.js';
9
9
  import type {Cronli5Options} from '../../types.js';
@@ -197,14 +197,41 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
197
197
  const clockRest = plan.rest.kind === 'clockTimes' ||
198
198
  plan.rest.kind === 'compactClockTimes';
199
199
 
200
- return clockRest && ir.shapes.minute === 'single' ?
201
- hourCadence(ir, +ir.pattern.minute, opts) :
202
- null;
200
+ if (!clockRest || ir.shapes.minute !== 'single') {
201
+ return null;
202
+ }
203
+
204
+ const minute = +ir.pattern.minute;
205
+
206
+ return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
203
207
  }
204
208
 
205
209
  // A meaningful second under minute/hour shapes the earlier strategies
206
210
  // deferred on: the second leads with its own clause and the rest of the
207
211
  // pattern follows.
212
+ // A wildcard or stepped second under a fixed minute across one or more specific
213
+ // hours. The clock-time rest collapses the pinned minute into the hour, and on
214
+ // the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
215
+ // "9:00 a.m."), losing the one-minute confinement.
216
+ //
217
+ // A SINGLE minute-0 is the one-minute window at the top of each named hour: a
218
+ // duration frame ("for one minute at 9 a.m.") states the confinement outright,
219
+ // with the hour as its word so it cannot be heard as the hour itself. A minute
220
+ // LIST whose first value is 0 (e.g. */25 → :00, :25, :50) is a wall of distinct
221
+ // clock times, not one confinement, so it names each minute via the compact
222
+ // form, never collapsing to the bare hour (which once repeated it, "9 a.m.,
223
+ // 9 a.m."). A non-zero pinned minute is an unambiguous clock time the compact
224
+ // "of 9:05 a.m." form reads as the minute, never the hour.
225
+ function clockTimesConfinement(ir: IR, rest: PlanOf<'clockTimes'>,
226
+ opts: NormalizedOptions): string {
227
+ if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
228
+ return secondsLeadClause(ir, opts) + ' for one minute at ' +
229
+ durationHours(ir, rest, opts);
230
+ }
231
+
232
+ return secondsLeadClause(ir, opts) + ' of ' + clockTimesOf(ir, rest, opts);
233
+ }
234
+
208
235
  function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
209
236
  opts: NormalizedOptions): string {
210
237
  // An hour step (or arithmetic-progression hour list) under a single pinned
@@ -217,28 +244,11 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
217
244
  return cadence;
218
245
  }
219
246
 
220
- // A wildcard or stepped second under a minute pinned to a single value
221
- // across one or more specific hours. The clock-time rest collapses the
222
- // pinned minute into the hour, and on the clock a pinned minute-0 reads as
223
- // the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
224
- // confinement. (A second list/range/single leads with a "past the minute"
225
- // clause that an "of"/duration frame cannot follow, so it stays generic.)
247
+ // A wildcard or stepped second under a fixed minute across one or more
248
+ // specific hours confines the seconds to the clock time(s).
226
249
  if (plan.rest.kind === 'clockTimes' &&
227
250
  (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
228
- const minute = plan.rest.times[0].minute;
229
-
230
- // Minute 0 is the one-minute window at the top of each named hour: a
231
- // duration frame ("for one minute at 9 a.m.") states the confinement
232
- // outright, with the hour as its word so it cannot be heard as the hour
233
- // itself. A non-zero pinned minute is an unambiguous clock time, so the
234
- // compact "of 9:05 a.m." form reads it as the minute, never the hour.
235
- if (+minute === 0) {
236
- return secondsLeadClause(ir, opts) + ' for one minute at ' +
237
- durationHours(ir, plan.rest, opts);
238
- }
239
-
240
- return secondsLeadClause(ir, opts) + ' of ' +
241
- clockTimesOf(ir, plan.rest, opts);
251
+ return clockTimesConfinement(ir, plan.rest, opts);
242
252
  }
243
253
 
244
254
  // A wildcard second under a */2 minute step with a wildcard hour binds
@@ -254,7 +264,15 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
254
264
  trailingQualifier(ir, opts);
255
265
  }
256
266
 
257
- return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
267
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
268
+ // leading clause, so the composer must not prepend a second lead that would
269
+ // double it. A wildcard or stepped second is not folded there (no
270
+ // clockSecond), so it still leads its own clause here.
271
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
272
+ ir.analyses.clockSecond;
273
+ const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
274
+
275
+ return lead + render(ir, plan.rest, opts);
258
276
  }
259
277
 
260
278
  // The bare-hour words for a minute-0 duration confinement, joined and followed
@@ -381,9 +399,15 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
381
399
  'minute', 'hour', opts);
382
400
 
383
401
  if (plan.hours.kind === 'during') {
384
- // An hour list confines the cadence to each listed hour's window.
385
- phrase += ' during the ' +
386
- hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
402
+ // A uneven hour stride confines the minute cadence to its own bounded hour
403
+ // cadence ("every 15 minutes, every five hours from midnight through 8
404
+ // p.m."); an irregular hour list still names each hour's window.
405
+ const cadence = unevenHourCadence(ir, opts);
406
+
407
+ phrase += cadence ?
408
+ ', ' + cadence :
409
+ ' during the ' +
410
+ hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
387
411
  }
388
412
  else if (plan.hours.kind === 'window') {
389
413
  phrase += ' ' + hourWindow(plan.hours, opts);
@@ -422,13 +446,20 @@ function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
422
446
  // during each hour.
423
447
  function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
424
448
  opts: NormalizedOptions): string {
449
+ // A uneven hour stride reads as a cadence, not a wall of hour columns: the
450
+ // minute lead, then "every N hours from X through Y".
451
+ const cadence = unevenHourCadence(ir, opts);
452
+
425
453
  if (plan.form === 'wildcard') {
454
+ if (cadence !== null) {
455
+ return 'every minute, ' + cadence + trailingQualifier(ir, opts);
456
+ }
457
+
426
458
  return 'every minute during the ' +
427
459
  hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
428
460
  trailingQualifier(ir, opts);
429
461
  }
430
462
 
431
- const times = hourTimesFromPlan(ir, plan.times, true, opts);
432
463
  const lead = plan.form === 'range' ?
433
464
  minuteRangeLead(ir.pattern.minute, opts) :
434
465
  // The 'list' form is a minute list, which has segments; an offset/uneven
@@ -437,6 +468,12 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
437
468
  listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
438
469
  'minute', 'hour', opts);
439
470
 
471
+ if (cadence !== null) {
472
+ return lead + ', ' + cadence + trailingQualifier(ir, opts);
473
+ }
474
+
475
+ const times = hourTimesFromPlan(ir, plan.times, true, opts);
476
+
440
477
  return lead + ', at ' + times + trailingQualifier(ir, opts);
441
478
  }
442
479
 
@@ -466,6 +503,9 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
466
503
  // segment is a step segment.
467
504
  const segment = ir.analyses.segments.hour![0] as StepSegment;
468
505
 
506
+ // A wildcard minute over a stepped hour is reached only for a clean stride
507
+ // (a bounded or uneven step routes through minutesAcrossHours instead), so it
508
+ // confines to every Nth hour without a bounded-cadence case here.
469
509
  if (plan.form === 'wildcard') {
470
510
  return 'every minute ' + everyNthHour(segment, opts) +
471
511
  trailingQualifier(ir, opts);
@@ -478,8 +518,13 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
478
518
  listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
479
519
  'minute', 'hour', opts) :
480
520
  minuteRangeLead(ir.pattern.minute, opts);
521
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence after
522
+ // the minute lead, not a wall of clock-time columns; an offset-clean step
523
+ // keeps its existing per-step phrasing.
524
+ const cadence = unevenHourCadence(ir, opts);
481
525
 
482
- return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
526
+ return lead + ', ' +
527
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
483
528
  }
484
529
 
485
530
  // Lead phrase for a plain minute range: "every minute from <a> through <b>
@@ -536,18 +581,32 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
536
581
 
537
582
  function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
538
583
  opts: NormalizedOptions): string {
584
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence ("every
585
+ // two hours from 9 a.m. through 5 p.m."), the same form the compound paths
586
+ // speak; an offset-clean step keeps its bare or "from M" cadence.
587
+ const cadence = unevenHourCadence(ir, opts);
588
+
589
+ if (cadence !== null) {
590
+ return cadence + trailingQualifier(ir, opts);
591
+ }
592
+
539
593
  // An hour-step plan is selected only for a stepped hour field, whose
540
594
  // first segment is a step segment.
541
595
  return stepHours(ir.analyses.segments.hour![0] as StepSegment, opts) +
542
596
  trailingQualifier(ir, opts);
543
597
  }
544
598
 
545
- // The hour-range plan as a window whose closing minute honors `boundMinute`:
546
- // a bare close (`null`) lands on the top of the final hour (`:00`), matching
547
- // the minute-0 baseline, with the minutes stated separately elsewhere.
599
+ // The hour-range plan as a window. The close lands on the top of the final
600
+ // hour (`:00`) unless the minute genuinely runs to the end of that hour i.e.
601
+ // a wildcard minute, which fills every minute and states no separate clause.
602
+ // A pinned/listed/ranged minute is named in its own lead clause, so folding it
603
+ // into the close too would read as a span ("through 5:05 p.m.") that
604
+ // contradicts the minute clause; the window stays bare ("through 5 p.m.").
548
605
  function boundedWindow(plan: PlanOf<'hourRange'>):
549
606
  {from: number; to: number; last: number} {
550
- return {from: plan.from, last: plan.boundMinute ?? 0, to: plan.to};
607
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
608
+
609
+ return {from: plan.from, last, to: plan.to};
551
610
  }
552
611
 
553
612
  // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.". Windows
@@ -563,10 +622,13 @@ function hourWindow(window: {from: number; to: number; last: number},
563
622
  // a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
564
623
  function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
565
624
  opts: NormalizedOptions): string {
566
- // An hour step (or arithmetic-progression hour list) under a single pinned
567
- // minute reads as a cadence rather than a cross-product of clock times.
625
+ // An hour step or range (or arithmetic-progression hour list) under a
626
+ // single pinned minute reads as a cadence or window rather than a
627
+ // cross-product of clock times.
568
628
  if (ir.shapes.minute === 'single') {
569
- const cadence = hourCadence(ir, +ir.pattern.minute, opts);
629
+ const minute = +ir.pattern.minute;
630
+ const cadence = hourCadence(ir, minute, opts) ??
631
+ hourRangeCadence(ir, minute, opts);
570
632
 
571
633
  if (cadence !== null) {
572
634
  return cadence;
@@ -592,10 +654,11 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
592
654
  function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
593
655
  opts: NormalizedOptions): string {
594
656
  if (plan.fold) {
595
- // An hour step (or arithmetic-progression hour list) under the single
596
- // pinned minute reads as a cadence, not a wall of clock times. (Returns
597
- // null for an irregular list or a range, which keep folding below.)
598
- const cadence = hourCadence(ir, +plan.minute, opts);
657
+ // An hour step or range (or arithmetic-progression hour list) under the
658
+ // single pinned minute reads as a cadence or window, not a wall of clock
659
+ // times. (Returns null for an irregular list, which keeps folding below.)
660
+ const cadence = hourCadence(ir, +plan.minute, opts) ??
661
+ hourRangeCadence(ir, +plan.minute, opts);
599
662
 
600
663
  if (cadence !== null) {
601
664
  return cadence;
@@ -619,12 +682,18 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
619
682
  hourSegmentTimes(ir, fold, true, opts);
620
683
  }
621
684
 
622
- const phrase =
685
+ const minuteLead =
623
686
  // The non-fold branch is a minute list, which has segments. An
624
687
  // offset/uneven step enumerated to that list reads as a stride.
625
- (strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
688
+ strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
626
689
  listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
627
- 'minute', 'hour', opts)) +
690
+ 'minute', 'hour', opts);
691
+ // A uneven hour stride reads as a cadence after the minute lead, not a wall
692
+ // of clock-time columns.
693
+ const cadence = unevenHourCadence(ir, opts);
694
+ const phrase = cadence ?
695
+ minuteLead + ', ' + cadence + trailingQualifier(ir, opts) :
696
+ minuteLead +
628
697
  ', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
629
698
  trailingQualifier(ir, opts);
630
699
 
@@ -847,12 +916,74 @@ function hourStrideCadence(stride: {start: number; interval: number;
847
916
  through(opts) + getTime({hour: last, minute: 0}, opts);
848
917
  }
849
918
 
919
+ // Whether an hour stride wraps the day cleanly from within its first interval
920
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
921
+ // stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
922
+ // other stride — a uneven interval, or one starting at or past its interval
923
+ // (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
924
+ function offsetCleanStride(
925
+ stride: {start: number; interval: number}
926
+ ): boolean {
927
+ return stride.start < stride.interval && 24 % stride.interval === 0;
928
+ }
929
+
930
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
931
+ // or null when the hour is not such a stride. The core rewrites a uneven step
932
+ // to its fire list, so a minute window/list/step crossed with it lands in the
933
+ // enumerating list paths; there the bounded hour reads better as its cadence
934
+ // ("…, every five hours from midnight through 8 p.m.") than as a wall of
935
+ // clock-time columns. An offset-clean stride keeps its existing confinement
936
+ // form, so only the endpoint-bearing case routes here.
937
+ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
938
+ const stride = hourStride(ir);
939
+
940
+ if (!stride || offsetCleanStride(stride)) {
941
+ return null;
942
+ }
943
+
944
+ return hourStrideCadence(stride, opts);
945
+ }
946
+
947
+ // An hour list's arithmetic progression, or null when its values are not a
948
+ // step the renderer should speak as a cadence. The core rewrites a uneven hour
949
+ // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
950
+ // literal fire list, indistinguishable in the IR from a hand-written list; the
951
+ // renderer recovers the cadence from the values. A progression starting at
952
+ // zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
953
+ // only a step when it is too long to be a deliberate clock-time list (e.g.
954
+ // 9,17 is two named times, not a cadence), the same length the minute/second
955
+ // list path uses. Interval one is a plain range, never a step.
956
+ function hourListStride(values: number[]):
957
+ {start: number; interval: number; last: number} | null {
958
+ if (values.length < 2) {
959
+ return null;
960
+ }
961
+
962
+ const interval = values[1] - values[0];
963
+
964
+ if (interval < 2) {
965
+ return null;
966
+ }
967
+
968
+ for (let i = 2; i < values.length; i += 1) {
969
+ if (values[i] - values[i - 1] !== interval) {
970
+ return null;
971
+ }
972
+ }
973
+
974
+ if (values[0] !== 0 && values.length < 5) {
975
+ return null;
976
+ }
977
+
978
+ return {interval, last: values[values.length - 1], start: values[0]};
979
+ }
980
+
850
981
  // The hour field's stride, or null when the hour is not a cadence: a step
851
982
  // segment yields its {start, interval, last} directly; an all-single hour
852
- // list yields one only when its values form a long-enough arithmetic
853
- // progression (so an irregular list like 9,17 keeps enumerating). The IR is
854
- // unchanged — the renderer recognizes the stride and speaks it as a cadence
855
- // instead of the clock-time cross-product.
983
+ // list yields one only when its values form a step progression (so an irregular
984
+ // list like 9,17 keeps enumerating). The IR is unchanged — the renderer
985
+ // recognizes the stride and speaks it as a cadence instead of the clock-time
986
+ // cross-product.
856
987
  function hourStride(ir: IR):
857
988
  {start: number; interval: number; last: number} | null {
858
989
  // Reached only from the clock-time paths, which run under discrete hours
@@ -861,6 +992,13 @@ function hourStride(ir: IR):
861
992
 
862
993
  if (segments.length === 1 && segments[0].kind === 'step') {
863
994
  const segment = segments[0];
995
+
996
+ // A bounded step that fires only once (e.g. `9-10/5` → just 9) is a single
997
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
998
+ if (segment.fires.length < 2) {
999
+ return null;
1000
+ }
1001
+
864
1002
  const start = segment.startToken === '*' ?
865
1003
  0 :
866
1004
  +segment.startToken.split('-')[0];
@@ -870,9 +1008,8 @@ function hourStride(ir: IR):
870
1008
  }
871
1009
 
872
1010
  const values = singleValues(segments);
873
- const step = values && arithmeticStep(values);
874
1011
 
875
- return step || null;
1012
+ return values && hourListStride(values);
876
1013
  }
877
1014
 
878
1015
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -931,7 +1068,13 @@ function hourCadence(ir: IR, minute: number,
931
1068
 
932
1069
  const fires = (stride.last - stride.start) / stride.interval + 1;
933
1070
 
934
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1071
+ // A short stride that spells out as few clock times stays an enumeration only
1072
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1073
+ // or "from M" form is no shorter than the list, so the list reads fine. A
1074
+ // bounded or uneven stride has no clean wrap, so its endpoint-pinning cadence
1075
+ // ("every five hours from midnight through 8 p.m.") reads better however few.
1076
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1077
+ offsetCleanStride(stride)) {
935
1078
  return null;
936
1079
  }
937
1080
 
@@ -948,6 +1091,14 @@ function hourCadence(ir: IR, minute: number,
948
1091
  everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
949
1092
  }
950
1093
 
1094
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1095
+ // lead clause to fold in, so the bounded cadence stands on its own ("every
1096
+ // five hours from midnight through 8 p.m."); only a real minute or second
1097
+ // prefixes its clause.
1098
+ if (minute === 0 && ir.pattern.second === '0') {
1099
+ return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1100
+ }
1101
+
951
1102
  return hourCadenceLead(ir, minute, opts) + ', ' +
952
1103
  hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
953
1104
  }
@@ -970,6 +1121,94 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
970
1121
  return segment;
971
1122
  }
972
1123
 
1124
+ // Whether the hour field is a range — or a list whose segments include a
1125
+ // range — and so forms a window rather than a cross-product of clock times.
1126
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1127
+ // a step is handled by hourStride/hourCadence, so a field whose only segments
1128
+ // are steps and singles is left alone here.
1129
+ function hasHourWindow(ir: IR): boolean {
1130
+ // Reached only from the clock-time paths, which run under discrete hours
1131
+ // and so always carry hour segments.
1132
+ return ir.analyses.segments.hour!.some(function range(segment) {
1133
+ return segment.kind === 'range';
1134
+ });
1135
+ }
1136
+
1137
+ // The hour-range window as a cadence tail at the top of each hour: each range
1138
+ // segment is a "from X through Y" window ("every hour from 9 a.m. through
1139
+ // 5 p.m."), and any non-contiguous single hour is appended ("and at 10 p.m.").
1140
+ // The minute has already folded into the lead, so the window closes on the
1141
+ // top of its final hour. Mirrors foldedHourWindows but pinned to minute 0.
1142
+ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1143
+ const windows: string[] = [];
1144
+ const singles: number[] = [];
1145
+
1146
+ // Reached only after hasHourWindow, so hour segments exist.
1147
+ ir.analyses.segments.hour!.forEach(function classify(segment) {
1148
+ if (segment.kind === 'range') {
1149
+ windows.push('from ' + getTime({hour: +segment.bounds[0], minute: 0},
1150
+ opts) + through(opts) +
1151
+ getTime({hour: +segment.bounds[1], minute: 0}, opts));
1152
+ }
1153
+ else if (segment.kind === 'step') {
1154
+ singles.push(...segment.fires);
1155
+ }
1156
+ else {
1157
+ singles.push(+segment.value);
1158
+ }
1159
+ });
1160
+
1161
+ let phrase = 'every hour ' + joinList(windows, opts);
1162
+
1163
+ if (singles.length) {
1164
+ phrase += ' and at ' + joinList(singles.map(function time(hour) {
1165
+ return getTime({hour, minute: 0}, opts);
1166
+ }), opts);
1167
+ }
1168
+
1169
+ return phrase;
1170
+ }
1171
+
1172
+ // Render an hour range (or a list whose segments include a range) under a
1173
+ // single pinned minute and a second as the hour-range window — the lead
1174
+ // clause, then "every hour from X through Y" — instead of cross-multiplying
1175
+ // the hours into a wall of clock times. Returns null when the hour has no
1176
+ // range (a pure single-value list, a single hour, or a step, which other
1177
+ // paths own), or when a plain :00 set is short enough that enumeration is no
1178
+ // longer than the window. Renderer-only; the IR is unchanged.
1179
+ function hourRangeCadence(ir: IR, minute: number,
1180
+ opts: NormalizedOptions): string | null {
1181
+ // Scoped to minute 0: the minute folds into the lead and every hour fires
1182
+ // at the top, so the window closes cleanly on the final hour. A non-zero
1183
+ // pinned minute is a real clock minute the existing clock-time window form
1184
+ // already speaks ("9:30:15 a.m. through 8:30:15 p.m."), unchanged.
1185
+ if (minute !== 0 || !hasHourWindow(ir)) {
1186
+ return null;
1187
+ }
1188
+
1189
+ // A plain top-of-minute second (:00) carries no clause: the existing
1190
+ // hour-range and folded-window renderers already speak that window, so this
1191
+ // path only forms a window when there is a meaningful second to lead with.
1192
+ if (ir.pattern.second === '0') {
1193
+ return null;
1194
+ }
1195
+
1196
+ // A wildcard or sub-minute step second confined to minute 0 is the whole
1197
+ // minute-0 window ("every second for one minute"), confined to the hour
1198
+ // range with the "during the … hours" idiom (the same idiom an hour list
1199
+ // uses). This is kept distinct from the bare minute-0 window ("every hour
1200
+ // from 9 a.m. through 5 p.m.") so the one-minute confinement is never heard
1201
+ // as it — the hour-range analog of "for one minute during every other hour".
1202
+ if (subMinuteSecond(ir)) {
1203
+ return secondsClause(ir, 'minute', opts) + ' for one minute during the ' +
1204
+ hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) +
1205
+ ' hours' + trailingQualifier(ir, opts);
1206
+ }
1207
+
1208
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1209
+ hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
1210
+ }
1211
+
973
1212
  // --- List and segment phrasing. ---
974
1213
 
975
1214
  // Chicago number style for a series: if any value crosses the spell-out
@@ -1265,29 +1504,51 @@ function monthFoldsIntoDate(ir: IR): boolean {
1265
1504
 
1266
1505
  // Compose the "day-of-month or day-of-week" phrase used when both fields
1267
1506
  // are restricted: cron fires when either is a match. A restricted month
1268
- // scopes both.
1507
+ // scopes BOTH halves, so it attaches to the whole or, never to a single
1508
+ // branch. When the month folds into a calendar date ("on June 13") it also
1509
+ // names itself on the weekday ("or on Friday in June"), keeping both halves
1510
+ // scoped; otherwise (a Quartz date, an open day step, a month range, or the
1511
+ // odd/even frequency) it trails the whole or as ", in <month>".
1269
1512
  function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
1270
1513
  const pattern = ir.pattern;
1271
1514
  const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
1272
1515
  'on ' + weekdayPhrase(ir, opts);
1516
+
1517
+ if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
1518
+ !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
1519
+ return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
1520
+ ' in ' + monthName(ir, opts);
1521
+ }
1522
+
1523
+ return datePart(ir, opts) + ' or ' + weekdayPart + orMonthScope(ir, opts);
1524
+ }
1525
+
1526
+ // The day-of-month half of an or-day phrase, without any month scope (the
1527
+ // month scopes the whole or, applied by the caller).
1528
+ function datePart(ir: IR, opts: NormalizedOptions): string {
1529
+ const pattern = ir.pattern;
1273
1530
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1274
1531
 
1275
1532
  if (quartzDate) {
1276
- return quartzDate + monthScope(ir, opts) + ' or ' + weekdayPart;
1533
+ return quartzDate;
1277
1534
  }
1278
1535
 
1279
1536
  if (isOpenStep(pattern.date)) {
1280
- return stepDates(pattern.date) + monthScope(ir, opts) + ' or ' +
1281
- weekdayPart;
1537
+ return stepDates(pattern.date);
1282
1538
  }
1283
1539
 
1284
- if (pattern.month !== '*' && monthFoldsIntoDate(ir)) {
1285
- return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
1286
- ' in ' + monthName(ir, opts);
1540
+ return 'on the ' + dateOrdinals(ir, opts);
1541
+ }
1542
+
1543
+ // A trailing month scope for the whole or, set off by a comma so it reads
1544
+ // over both day halves ("…or on Friday, in June"); empty when the month is a
1545
+ // wildcard.
1546
+ function orMonthScope(ir: IR, opts: NormalizedOptions): string {
1547
+ if (ir.pattern.month === '*') {
1548
+ return '';
1287
1549
  }
1288
1550
 
1289
- return 'on the ' + dateOrdinals(ir, opts) + ' or ' + weekdayPart +
1290
- monthScope(ir, opts);
1551
+ return ', in ' + monthName(ir, opts);
1291
1552
  }
1292
1553
 
1293
1554
  // The day-qualifier phrase for a Quartz date field (e.g. "on the last day
@@ -1431,8 +1692,12 @@ function oddEvenMonth(monthField: string): string | null {
1431
1692
  // Render the weekday field as names. Ranges read in their connective form
1432
1693
  // ("Monday through Friday", or "Mon-Fri" with `short`).
1433
1694
  function weekdayPhrase(ir: IR, opts: NormalizedOptions): string {
1434
- // Reached only with a restricted weekday, which has segments.
1435
- return renderSegments(ir.analyses.segments.weekday!, function name(value) {
1695
+ // Reached only with a restricted weekday, which has segments. Weekday lists
1696
+ // display Monday-first (Sunday last) so a weekend reads naturally; the IR
1697
+ // stays canonical (Sunday=0) and ranges keep their form.
1698
+ const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
1699
+
1700
+ return renderSegments(segments, function name(value) {
1436
1701
  return getWeekday(value, opts);
1437
1702
  }, opts);
1438
1703
  }