cronli5 0.8.0 → 0.8.3

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.
@@ -581,8 +581,11 @@ function renderMinuteFrequency(schedule: Schedule,
581
581
  else if (plan.hours.kind === 'step') {
582
582
  // The plan carries a step only for a clean stride (dividing the day),
583
583
  // which confines the cadence to every Nth hour; a stepped hour field's
584
- // first segment is a step segment.
585
- phrase += ' ' +
584
+ // first segment is a step segment. The hour step scopes the hours, so an
585
+ // offset cadence drops "past the hour" and joins with a comma.
586
+ const bound = withoutHourAnchor(phrase);
587
+
588
+ phrase = bound + (bound === phrase ? ' ' : ', ') +
586
589
  everyNthHour(stepSegment(schedule, 'hour'), opts);
587
590
  }
588
591
 
@@ -674,6 +677,67 @@ const stepOrdinals: Record<number, string> = {
674
677
  2: 'other', 3: 'third', 4: 'fourth', 6: 'sixth', 8: 'eighth', 12: 'twelfth'
675
678
  };
676
679
 
680
+ // Spelled ordinals for "every Nth minute" — the step intervals a minute
681
+ // cadence can take (2 reads idiomatically as "other"). A lookup miss falls back
682
+ // to the suffixed numeric ordinal, so an unusually large interval still reads.
683
+ const spelledOrdinals: Record<number, string> = {
684
+ 2: 'other', 3: 'third', 4: 'fourth', 5: 'fifth', 6: 'sixth', 7: 'seventh',
685
+ 8: 'eighth', 9: 'ninth', 10: 'tenth', 11: 'eleventh', 12: 'twelfth',
686
+ 15: 'fifteenth', 20: 'twentieth', 30: 'thirtieth'
687
+ };
688
+
689
+ // The ordinal word for a cadence interval ("sixth", "seventh"), spelled where
690
+ // known and suffixed-numeric ("13th") otherwise.
691
+ function ordinalWord(interval: number): string {
692
+ return spelledOrdinals[interval] ?? getOrdinal(interval);
693
+ }
694
+
695
+ // A stepped minute under a seconds lead reads as a CONFINEMENT of that cadence,
696
+ // not a juxtaposed clause (a comma there reads as two independent cadences) nor
697
+ // a wall of enumerated minutes: "during every Nth minute" plus the step's
698
+ // offset/bound. The cadence is ORDINAL ("every sixth minute"); the cardinal
699
+ // ("every six minutes") is the form that reads as a separate cadence. The
700
+ // offset/bound mirrors the standalone minute cadence: a clean stride from the
701
+ // top names no offset, an offset-clean stride names only its start ("from four
702
+ // minutes past the hour"), and an uneven one pins both endpoints ("from 2
703
+ // through 58 minutes past the hour").
704
+ function minuteStrideConfinement(stride: {start: number; interval: number;
705
+ last: number}, opts: NormalizedOptions): string {
706
+ const base = ' during every ' + ordinalWord(stride.interval) + ' minute';
707
+
708
+ return chooseStride({...stride, cycle: 60}, {
709
+ bare: () => base,
710
+ offset: () => base + ' from ' + getNumber(stride.start, opts) + ' ' +
711
+ pluralize(stride.start, 'minute') + ' past the hour',
712
+ bounded: () => {
713
+ const num = seriesNumber();
714
+
715
+ return base + ' from ' + num(stride.start) + through(opts) +
716
+ num(stride.last) + ' ' + pluralize(stride.last, 'minute') +
717
+ ' past the hour';
718
+ }
719
+ });
720
+ }
721
+
722
+ // The minute field's step stride for the confinement frame, or null when the
723
+ // minute is not a stepped cadence. A `step`-shaped field (`*/6`) reads its
724
+ // segment directly; a `list`-shaped field the core enumerated from an uneven
725
+ // step (`2/7` → 2,9,…,58) recovers the progression from its values.
726
+ function minuteStride(schedule: Schedule):
727
+ {start: number; interval: number; last: number} | null {
728
+ if (schedule.shapes.minute === 'step') {
729
+ const segment = stepSegment(schedule, 'minute');
730
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
731
+
732
+ return {interval: segment.interval, last:
733
+ segment.fires[segment.fires.length - 1], start};
734
+ }
735
+
736
+ const values = singleValues(segmentsOf(schedule, 'minute'));
737
+
738
+ return values && arithmeticStep(values);
739
+ }
740
+
677
741
  // Confine a cadence to a clean hour stride: "during every other hour", with
678
742
  // the start named when it is not midnight ("…from 1 a.m." for an odd stride).
679
743
  function everyNthHour(segment: StepSegment, opts: NormalizedOptions): string {
@@ -703,13 +767,14 @@ function renderMinuteSpanAcrossHourStep(schedule: Schedule,
703
767
  }
704
768
 
705
769
  // A minute list keeps the same cadence clause; only its lead differs. An
706
- // offset/uneven step the core enumerated to that list reads as a stride.
707
- const lead = plan.form === 'list' ?
770
+ // offset/uneven step the core enumerated to that list reads as a stride. The
771
+ // hour step scopes the hours, so the lead drops its generic "past the hour".
772
+ const lead = withoutHourAnchor(plan.form === 'list' ?
708
773
  strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
709
774
  opts) ??
710
775
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
711
776
  'minute', 'hour', opts) :
712
- minuteRangeLead(schedule.pattern.minute, opts);
777
+ minuteRangeLead(schedule.pattern.minute, opts));
713
778
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
714
779
  // the minute lead, not a wall of clock-time columns; an offset-clean step
715
780
  // keeps its existing per-step phrasing.
@@ -932,10 +997,14 @@ function renderCompactClockTimes(schedule: Schedule,
932
997
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
933
998
  'minute', 'hour', opts);
934
999
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
935
- // of clock-time columns.
1000
+ // of clock-time columns. The hour step is the sole hour authority there, so
1001
+ // the minute lead drops its generic "past the hour" (an every-hour scope that
1002
+ // would conflict with the step); the clock-time branch keeps it, naming
1003
+ // specific hours rather than a step.
936
1004
  const cadence = unevenHourCadence(schedule, opts);
937
1005
  const phrase = cadence ?
938
- minuteLead + ', ' + cadence + trailingQualifier(schedule, opts) :
1006
+ withoutHourAnchor(minuteLead) + ', ' + cadence +
1007
+ trailingQualifier(schedule, opts) :
939
1008
  minuteLead +
940
1009
  ', at ' +
941
1010
  hourSegmentTimes(schedule, {minute: 0, second: null}, true, opts) +
@@ -1070,12 +1139,22 @@ function minuteConfinement(schedule: Schedule,
1070
1139
  return '';
1071
1140
  }
1072
1141
 
1073
- if (isCadenceField(minute)) {
1074
- // The gate admits only the `*/2` "every other minute" step here; other
1075
- // minute steps defer to the existing renderer.
1142
+ if (minute === '*/2') {
1143
+ // The `*/2` clean step reads idiomatically as "every other minute" with no
1144
+ // offset; other minute steps take the ordinal stride-cadence below.
1076
1145
  return ' of every other minute';
1077
1146
  }
1078
1147
 
1148
+ // A stepped minute (a clean `*/n`, an offset `m/n`, or a uneven step the core
1149
+ // enumerated to an arithmetic list) confines as "during every Nth minute"
1150
+ // plus the step's offset/bound — the ordinal cadence, not the cardinal that
1151
+ // reads as a separate cadence, nor a wall of enumerated ":NN" minutes.
1152
+ const stride = minuteStride(schedule);
1153
+
1154
+ if (stride) {
1155
+ return minuteStrideConfinement(stride, opts);
1156
+ }
1157
+
1079
1158
  // A minute single/range/list under the seconds lead. The minute reads as a
1080
1159
  // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
1081
1160
  // the minute-lead clock-point form).
@@ -1111,10 +1190,11 @@ function hourConfinement(schedule: Schedule, opts: NormalizedOptions): string {
1111
1190
 
1112
1191
  if (hour === '*') {
1113
1192
  // A pinned minute confinement ("during minute :00") repeats across every
1114
- // hour, so the hour is named as the unit of recurrence; a stepped minute
1115
- // ("of every other minute") or absent minute already implies all hours.
1193
+ // hour, so the hour is named as the unit of recurrence; a minute cadence
1194
+ // ("of every other minute", "during every sixth minute …") or an absent
1195
+ // minute already implies all hours, so the hour is not restated.
1116
1196
  const minutePinned = schedule.pattern.minute !== '*' &&
1117
- !isCadenceField(schedule.pattern.minute);
1197
+ !isCadenceField(schedule.pattern.minute) && !minuteStride(schedule);
1118
1198
 
1119
1199
  return minutePinned ? ' of every hour' : '';
1120
1200
  }
@@ -1208,24 +1288,28 @@ function confinementEligible(schedule: Schedule,
1208
1288
  }
1209
1289
 
1210
1290
  if (lead.secondLead) {
1211
- // A minute STEP is supported only as the `*/2` "every other minute" idiom,
1212
- // and only where it fills the coarser field: a contiguous hour range or a
1213
- // single hour both close on the minute's real last fire, which the
1214
- // windowing renderer already speaks. The `*/2` step fills both, so it keeps
1215
- // the "of every other minute" confinement; other steps defer entirely. A
1216
- // contiguous hour range (`hour === 'range'`) is left to that windowing
1217
- // renderer rather than this confinement frame, which closes on the top of
1218
- // the next hour.
1291
+ // A minute STEP confines as an ordinal cadence ("during every sixth minute
1292
+ // from four minutes past the hour"), but only where it fills the coarser
1293
+ // field: under a WILDCARD hour the step repeats every hour, so the cadence
1294
+ // is the whole confinement. A single hour or a contiguous range closes on
1295
+ // the minute's real last fire, which the windowing renderer already speaks,
1296
+ // so those defer. The `*/2` step keeps its "of every other minute" idiom.
1219
1297
  if (minuteStep) {
1220
- return minute === '*/2' && schedule.shapes.hour !== 'range';
1298
+ return minute === '*/2' ?
1299
+ schedule.shapes.hour !== 'range' :
1300
+ schedule.pattern.hour === '*';
1221
1301
  }
1222
1302
 
1223
- // A minute list that is really a stride keeps its cadence form; a short
1224
- // explicit minute list crossed with a discrete hour LIST is a wall of
1225
- // distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
1226
- // confinement. Both stay with the enumerating renderer.
1227
- if (isMinuteStride(schedule) ||
1228
- schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
1303
+ // A minute list that is really an arithmetic stride confines as that same
1304
+ // ordinal cadence when it fills a wildcard hour; under a restricted hour it
1305
+ // keeps its existing cadence form. A short explicit minute list crossed
1306
+ // with a discrete hour LIST is a wall of distinct clock times ("9:00 a.m.,
1307
+ // 9:25 a.m., …"), not a single minute confinement, so it stays enumerated.
1308
+ if (isMinuteStride(schedule)) {
1309
+ return schedule.pattern.hour === '*';
1310
+ }
1311
+
1312
+ if (schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
1229
1313
  return false;
1230
1314
  }
1231
1315
 
@@ -1748,6 +1832,15 @@ function listPastThe(words: (string | number)[], unit: string, anchor: string,
1748
1832
  anchor;
1749
1833
  }
1750
1834
 
1835
+ // Strip the generic "past the hour" anchor from a minute-cadence lead. When the
1836
+ // hour field is restricted (a step or window), the hour clause is the sole hour
1837
+ // authority, so the cadence must not also assert "every hour" — "past the hour"
1838
+ // alongside a stepped/windowed hour reads as a conflicting every-hour scope. An
1839
+ // unrestricted hour keeps the anchor (it is the only hour statement there).
1840
+ function withoutHourAnchor(lead: string): string {
1841
+ return lead.replace(/ past the hour$/, '');
1842
+ }
1843
+
1751
1844
  // A clock time reads as a word ("noon"/"midnight") only at exact 12:00 or
1752
1845
  // 0:00 with no minute or second.
1753
1846
  function wordTime(hour: number | string, minute: number | string,
@@ -115,6 +115,17 @@ const weekdayNames = [
115
115
  const nthWeekdayNames =
116
116
  [null, 'primer', 'segundo', 'tercer', 'cuarto', 'quinto'];
117
117
 
118
+ // Spanish ordinals (masculine) for a stepped-minute cadence under a seconds
119
+ // lead ("cada sexto minuto"). The interval-2 step never reaches here — it keeps
120
+ // its own "de cada dos minutos" idiom — so the colliding "segundo" is unused.
121
+ // A lookup miss falls back to the cardinal-with-"cada" form, which still
122
+ // confines (see `minuteStepOrdinal`).
123
+ const stepOrdinals: Record<number, string> = {
124
+ 3: 'tercer', 4: 'cuarto', 5: 'quinto', 6: 'sexto', 7: 'séptimo',
125
+ 8: 'octavo', 9: 'noveno', 10: 'décimo', 12: 'duodécimo', 15: 'decimoquinto',
126
+ 20: 'vigésimo', 30: 'trigésimo'
127
+ };
128
+
118
129
  // Normalize raw user options.
119
130
  function normalizeOptions(options?: Cronli5Options): Opts {
120
131
  options = options || {};
@@ -269,6 +280,71 @@ function isPinnedMinuteSeconds(
269
280
  schedule.shapes.second === 'step');
270
281
  }
271
282
 
283
+ // The minute field's step stride for the confinement frame, or null when the
284
+ // minute is not a stepped cadence. A `step`-shaped field (`*/6`) reads its
285
+ // segment; a `list`-shaped field the core enumerated from a uneven step (`2/7`
286
+ // → 2,9,…,58) recovers the progression from its values.
287
+ function minuteStride(
288
+ schedule: Schedule
289
+ ): {start: number; interval: number; last: number} | null {
290
+ if (schedule.shapes.minute === 'step') {
291
+ const segment = stepSegment(schedule, 'minute');
292
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
293
+
294
+ return {interval: segment.interval, last:
295
+ segment.fires[segment.fires.length - 1], start};
296
+ }
297
+
298
+ const values = singleValues(segmentsOf(schedule, 'minute'));
299
+
300
+ return values && arithmeticStep(values);
301
+ }
302
+
303
+ // A stepped minute under a wildcard second and wildcard hour: bind the second
304
+ // cadence to the minute cadence as a CONFINEMENT ("cada segundo en cada sexto
305
+ // minuto a partir del minuto 4 de cada hora"), never the comma juxtaposition
306
+ // that reads as two independent cadences. The cadence is ORDINAL ("cada sexto
307
+ // minuto") — the cardinal "cada seis minutos" is what fuels the misread — and
308
+ // the start/bound mirror the standalone minute cadence: a clean step from the
309
+ // top names no offset, an offset-clean stride names only its start, and a
310
+ // uneven one pins both endpoints ("del minuto 2 al 58"). An interval the
311
+ // ordinal table does not cover keeps the cardinal "cada N" after "en", which
312
+ // still confines.
313
+ function minuteStepConfinement(
314
+ schedule: Schedule,
315
+ stride: {start: number; interval: number; last: number},
316
+ opts: Opts
317
+ ): string {
318
+ const ordinal = stepOrdinals[stride.interval];
319
+ const head = ordinal ?
320
+ 'cada ' + ordinal + ' minuto' :
321
+ 'cada ' + numero(stride.interval, opts) + ' minutos';
322
+
323
+ const tail = chooseStride({...stride, cycle: 60}, {
324
+ bare: () => '',
325
+ offset: () => ' a partir del minuto ' + stride.start,
326
+ bounded: () => ' del minuto ' + stride.start + ' al ' + stride.last
327
+ });
328
+
329
+ return secondsLeadClause(schedule, opts) + ' en ' + head + tail +
330
+ ' de cada hora' + trailingQualifier(schedule, opts);
331
+ }
332
+
333
+ // Whether a stepped minute fills a wildcard hour under a wildcard second — the
334
+ // shape the confinement frame above handles.
335
+ function isSteppedMinuteSeconds(
336
+ schedule: Schedule,
337
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
338
+ ): boolean {
339
+ return (plan.rest.kind === 'minuteFrequency' ||
340
+ plan.rest.kind === 'multipleMinutes') &&
341
+ (schedule.shapes.second === 'wildcard' ||
342
+ schedule.shapes.second === 'step') &&
343
+ schedule.shapes.hour === 'wildcard' &&
344
+ schedule.pattern.minute !== '*/2' &&
345
+ minuteStride(schedule) !== null;
346
+ }
347
+
272
348
  function renderComposeSeconds(
273
349
  schedule: Schedule,
274
350
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -312,6 +388,14 @@ function renderComposeSeconds(
312
388
  return dayFrame + ', ' + window + ', ' + cadence;
313
389
  }
314
390
 
391
+ // A stepped minute under a wildcard second + wildcard hour confines the
392
+ // second cadence to the ordinal minute cadence ("cada segundo en cada sexto
393
+ // minuto a partir del minuto 4 de cada hora"), never the comma juxtaposition
394
+ // that reads as two independent cadences.
395
+ if (isSteppedMinuteSeconds(schedule, plan)) {
396
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
397
+ }
398
+
315
399
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
316
400
  // cadences that read as contradictory ("cada segundo, cada dos minutos").
317
401
  // Bind them with the genitive "de" ("cada segundo de cada dos minutos"),
@@ -467,6 +551,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
467
551
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
468
552
  }
469
553
 
554
+ // Strip the generic "de cada hora" anchor from a minute-cadence lead. Under an
555
+ // hour STEP the hour clause is the sole hour authority, so the cadence must not
556
+ // also assert "de cada hora" — alongside a stepped hour it reads as a
557
+ // conflicting every-hour scope ("de cada hora, cada cuatro horas"). An hour
558
+ // WINDOW and an unrestricted hour keep the anchor (the window already names the
559
+ // hours; an open hour has no other hour statement).
560
+ function withoutHourAnchor(lead: string): string {
561
+ return lead.replace(/ de cada hora$/, '');
562
+ }
563
+
470
564
  // "cada minuto del 0 al 30". The standalone renderer adds "de cada hora";
471
565
  // when an hour qualifier follows ("..., a las 09:00", "..., cada dos
472
566
  // horas") it would contradict, so it is not baked in here.
@@ -594,8 +688,10 @@ function renderMinuteFrequency(
594
688
  }
595
689
  else if (plan.hours.kind === 'step') {
596
690
  // A clean stride is a confinement ("las horas pares", or the active-hour
597
- // list), never a juxtaposed cadence ("cada dos horas").
598
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
691
+ // list), never a juxtaposed cadence ("cada dos horas"). The hour step
692
+ // scopes the hours, so an offset cadence drops "de cada hora".
693
+ phrase = withoutHourAnchor(phrase) + ', ' +
694
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
599
695
  }
600
696
 
601
697
  return phrase + trailingQualifier(schedule, opts);
@@ -680,10 +776,10 @@ function renderMinuteSpanAcrossHourStep(
680
776
 
681
777
  // A minute list keeps the same cadence clause as the range; only its lead
682
778
  // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
683
- // 30").
684
- const lead = plan.form === 'list' ?
779
+ // 30"). The hour step scopes the hours, so the lead drops "de cada hora".
780
+ const lead = withoutHourAnchor(plan.form === 'list' ?
685
781
  minutesList(schedule, opts) :
686
- minuteRangeLead(schedule.pattern.minute);
782
+ minuteRangeLead(schedule.pattern.minute));
687
783
 
688
784
  return lead + ', ' +
689
785
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1271,10 +1367,13 @@ function renderCompactClockTimes(
1271
1367
  }
1272
1368
 
1273
1369
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1274
- // of clock-time columns.
1370
+ // of clock-time columns. That hour step is the sole hour authority, so the
1371
+ // minute lead drops its generic "de cada hora" (an every-hour scope that
1372
+ // would conflict with the step); the clock-time branch keeps it, naming
1373
+ // specific hours rather than a step.
1275
1374
  const cadence = unevenHourCadence(schedule, opts);
1276
1375
  const phrase = cadence ?
1277
- minutesList(schedule, opts) + ', ' + cadence +
1376
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1278
1377
  trailingQualifier(schedule, opts) :
1279
1378
  minutesList(schedule, opts) + ', ' +
1280
1379
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -123,6 +123,17 @@ const nthWeekdayNames: (string | null)[] = [
123
123
  'viidentenä'
124
124
  ];
125
125
 
126
+ // Essive ordinals for "joka N:ntenä minuuttina" — the step intervals a minute
127
+ // cadence can take. The interval-2 step keeps its own "joka toisena minuuttina"
128
+ // idiom and never reaches the confinement helper; a lookup miss falls back to
129
+ // the genitive "N minuutin välein" cadence, which still confines.
130
+ const minuteStepOrdinals: {[interval: number]: string} = {
131
+ 3: 'kolmantena', 4: 'neljäntenä', 5: 'viidentenä', 6: 'kuudentena',
132
+ 7: 'seitsemäntenä', 8: 'kahdeksantena', 9: 'yhdeksäntenä',
133
+ 10: 'kymmenentenä', 12: 'kahdentenatoista', 15: 'viidentenätoista',
134
+ 20: 'kahdentenakymmenentenä', 30: 'kolmantenakymmenentenä'
135
+ };
136
+
126
137
  // Weekdays as stored inflected forms (SUN..SAT): distributive -isin,
127
138
  // elative, illative, and essive. Consonant gradation (keskiviikko →
128
139
  // keskiviikosta) makes stem+suffix logic wrong; store the forms.
@@ -404,6 +415,71 @@ function composeHourCadence(
404
415
  hourRangeCadence(schedule, minute, opts);
405
416
  }
406
417
 
418
+ // The minute field's step stride for the confinement frame, or null when the
419
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
420
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
421
+ // recovers the progression from its values.
422
+ function minuteStride(
423
+ schedule: Schedule
424
+ ): {start: number; interval: number; last: number} | null {
425
+ if (schedule.shapes.minute === 'step') {
426
+ const segment = stepSegment(schedule, 'minute');
427
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
428
+
429
+ return {interval: segment.interval, last:
430
+ segment.fires[segment.fires.length - 1], start};
431
+ }
432
+
433
+ const values = singleValues(segmentsOf(schedule, 'minute'));
434
+
435
+ return values && arithmeticStep(values);
436
+ }
437
+
438
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
439
+ // second cadence to the minute cadence as a CONFINEMENT ("joka sekunti joka
440
+ // kuudentena minuuttina jokaisen tunnin minuutista 4 alkaen"), never the comma
441
+ // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
442
+ // ("joka kuudentena minuuttina") — the cardinal "kuuden minuutin välein" is
443
+ // what fuels the misread — and the start/bound mirror the standalone minute
444
+ // cadence: an offset-clean stride names only its start, a uneven one pins both
445
+ // endpoints ("minuutista 2 minuuttiin 58").
446
+ function minuteStepConfinement(
447
+ schedule: Schedule,
448
+ stride: {start: number; interval: number; last: number},
449
+ opts: NormalizedOptions
450
+ ): string {
451
+ const ordinalForm = minuteStepOrdinals[stride.interval];
452
+ const minute = units.minute;
453
+ const head = ordinalForm ?
454
+ ' joka ' + ordinalForm + ' minuuttina' :
455
+ ' ' + genitive(stride.interval, opts) + ' ' + minute.gen + ' välein';
456
+
457
+ const tail = chooseStride({...stride, cycle: 60}, {
458
+ bare: () => '',
459
+ offset: () => ' ' + minute.anchor + ' ' + minute.ela + ' ' +
460
+ stride.start + ' alkaen',
461
+ bounded: () => ' ' + minute.ela + ' ' + stride.start + ' ' +
462
+ minute.ill + ' ' + stride.last
463
+ });
464
+
465
+ return secondsLeadClause(schedule, opts) + head + tail +
466
+ trailingQualifier(schedule, opts);
467
+ }
468
+
469
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
470
+ // second — the shape the confinement frame above handles.
471
+ function isSteppedMinuteSeconds(
472
+ schedule: Schedule,
473
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
474
+ ): boolean {
475
+ return (plan.rest.kind === 'minuteFrequency' ||
476
+ plan.rest.kind === 'multipleMinutes') &&
477
+ (schedule.pattern.second === '*' || schedule.shapes.second === 'step') &&
478
+ schedule.shapes.hour === 'wildcard' &&
479
+ schedule.pattern.minute !== '*/2' &&
480
+ minuteStride(schedule) !== null;
481
+ }
482
+
407
483
  function renderComposeSeconds(
408
484
  schedule: Schedule,
409
485
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -419,6 +495,16 @@ function renderComposeSeconds(
419
495
  return cadence;
420
496
  }
421
497
 
498
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
499
+ // the second cadence to the ordinal minute cadence ("joka sekunti joka
500
+ // kuudentena minuuttina jokaisen tunnin minuutista 4 alkaen"), never the
501
+ // comma juxtaposition that reads as two independent cadences. Checked before
502
+ // the general minute-step compose path, which keeps the comma form under a
503
+ // restricted hour.
504
+ if (isSteppedMinuteSeconds(schedule, plan)) {
505
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
506
+ }
507
+
422
508
  // When the rest is a minute-step cadence, the step leads and the second
423
509
  // anchor follows after a comma (the comma marks the granularity boundary
424
510
  // between the two levels, not a flat list).
@@ -426,15 +512,15 @@ function renderComposeSeconds(
426
512
  return composeSecondsOverMinuteStep(schedule, plan.rest, opts);
427
513
  }
428
514
 
429
- // A sub-minute second with the minute pinned to 0 and a specific hour: the
430
- // clock-time rest would read "klo 9", dropping the pinned :00 and so the
431
- // one-minute confinement (60 fires in :00, not 3,600 across the hour). Bind
432
- // the seconds to the explicit clock minute with the "minuutin HH.00 aikana"
433
- // frame (an "of"/during form, never a range) and trail the day qualifier
434
- // ("joka sekunti minuutin 9.00 aikana, joka päivä").
435
- if (plan.rest.kind === 'clockTimes' &&
436
- plan.rest.times.every((time) => +time.minute === 0)) {
437
- return composeMinuteZero(schedule, plan.rest, opts);
515
+ // A second over a single fixed minute and a specific hour is a single fixed
516
+ // timestamp: the clock-time rest would float the seconds as a separate clause
517
+ // ("joka sekunti, joka päivä klo 9.02"), hiding that they belong to that one
518
+ // minute. Bind the seconds to the explicit clock minute with the "minuutin
519
+ // HH.MM aikana" frame (an "of"/during form, never a range) and trail the day
520
+ // qualifier ("joka sekunti minuutin 9.02 aikana, joka päivä") — the same
521
+ // fusion the minute-0 case ("minuutin 9.00 aikana") uses.
522
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.minute === 'single') {
523
+ return composeSingleMinute(schedule, plan.rest, opts);
438
524
  }
439
525
 
440
526
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
@@ -473,11 +559,13 @@ function isEveryOtherMinuteSeconds(
473
559
  return seg.startToken === '*' && seg.interval === 2;
474
560
  }
475
561
 
476
- // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
477
- // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
478
- // a range — a range would round-trip back to the whole hour) and trail the day
479
- // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
480
- function composeMinuteZero(
562
+ // The single-fixed-minute confinement: bind the seconds to the explicit clock
563
+ // minute(s) in the "minuutin/minuuttien HH.MM aikana" frame (an "of"/during
564
+ // form, never a range — a range would round-trip back to the whole hour) and
565
+ // trail the day qualifier ("joka sekunti minuutin 9.02 aikana, joka päivä").
566
+ // Minute 0 ("minuutin 9.00 aikana") is just this with the minute being 0; any
567
+ // single fixed minute fuses the same way.
568
+ function composeSingleMinute(
481
569
  schedule: Schedule,
482
570
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
483
571
  opts: NormalizedOptions
@@ -695,8 +783,10 @@ function renderMinuteFrequency(
695
783
  const cadence = unevenHourCadence(schedule, opts);
696
784
 
697
785
  if (cadence !== null) {
698
- return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
699
- trailingQualifier(schedule, opts);
786
+ // The hour step is the sole hour authority, so an offset minute cadence
787
+ // drops its generic "jokaisen tunnin" every-hour scope.
788
+ return withoutHourAnchor(stepCycle60(seg, units.minute, opts)) + ', ' +
789
+ cadence + trailingQualifier(schedule, opts);
700
790
  }
701
791
 
702
792
  // When the step renders as anchored ("kohdalla"), the per-hour windows
@@ -714,13 +804,16 @@ function renderMinuteFrequency(
714
804
  trailingQualifier(schedule, opts);
715
805
  }
716
806
 
717
- let phrase = stepCycle60(seg, units.minute, opts);
807
+ const phraseBase = stepCycle60(seg, units.minute, opts);
808
+ let phrase = phraseBase;
718
809
 
719
810
  if (plan.hours.kind === 'window') {
720
811
  phrase += ' ' + hourWindow(plan.hours, opts);
721
812
  }
722
813
  else if (plan.hours.kind === 'step') {
723
- phrase += ' ' +
814
+ // The hour step is the sole hour authority, so the minute cadence drops its
815
+ // generic "jokaisen tunnin" every-hour scope.
816
+ phrase = withoutHourAnchor(phraseBase) + ' ' +
724
817
  everyNthHour(stepSegment(schedule, 'hour'), opts);
725
818
  }
726
819
 
@@ -1167,6 +1260,16 @@ function stepCycle60(
1167
1260
  }, opts);
1168
1261
  }
1169
1262
 
1263
+ // Strip the generic "jokaisen tunnin" anchor from an offset minute-cadence
1264
+ // lead. When the hour field is a restricted step, the hour clause is the sole
1265
+ // hour authority, so the cadence must not also assert "jokaisen tunnin" (every
1266
+ // hour) — alongside a stepped hour it conflicts as an every-hour scope.
1267
+ // An unrestricted hour, and an hour WINDOW, keep the anchor (the window names
1268
+ // the hours without an every-hour-of-the-day conflict).
1269
+ function withoutHourAnchor(lead: string): string {
1270
+ return lead.replace(' ' + units.minute.anchor + ' ', ' ');
1271
+ }
1272
+
1170
1273
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
1171
1274
  // klo 1 alkaen".
1172
1275
  function stepHours(segment: StepSegment, opts: NormalizedOptions): string {