cronli5 0.7.2 → 0.8.2

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.
@@ -738,12 +738,13 @@ function renderComposeSeconds(
738
738
  }
739
739
  }
740
740
 
741
- // A sub-minute second with the minute pinned to 0 and a specific hour: the
742
- // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
743
- // the one-minute confinement 60 fires in :00, not 3,600 across the hour).
744
- // Bind the seconds into the explicit clock minute in the genitive ("der
745
- // Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
746
- if (composeMinuteZero(schedule, plan)) {
741
+ // A second over a single fixed minute and a specific hour is a single fixed
742
+ // timestamp: the clock-time rest would float the seconds as a separate
743
+ // apposition ("jede Sekunde, um 9:02 Uhr"), hiding that they belong to that
744
+ // one minute. Bind the seconds into the explicit clock minute in the genitive
745
+ // ("der Minute 9:02"), the same fusion the minute-0 case ("der Minute 9:00")
746
+ // uses; the recurring "täglich"/day frame is added in `describe`.
747
+ if (composeSingleMinute(schedule, plan)) {
747
748
  return secondsLead(schedule) + ' ' +
748
749
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
749
750
  }
@@ -765,21 +766,23 @@ function renderComposeSeconds(
765
766
  return lead + render(schedule, plan.rest, opts);
766
767
  }
767
768
 
768
- // True when a compose-seconds plan is a sub-minute second over a minute-0
769
- // clock-time rest — the case that reads as the bare hour and so must surface
770
- // the pinned clock minute.
771
- function composeMinuteZero(
769
+ // True when a compose-seconds plan is a sub-minute second over a single fixed
770
+ // minute's clock-time rest — the single fixed timestamp whose seconds must fuse
771
+ // to the explicit clock minute rather than float as a separate apposition.
772
+ // Minute 0 ("der Minute 0:00") is just this with the minute being 0; any single
773
+ // fixed minute fuses the same way.
774
+ function composeSingleMinute(
772
775
  schedule: Schedule,
773
776
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
774
777
  ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
775
778
  {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
776
779
  return plan.rest.kind === 'clockTimes' &&
777
- plan.rest.times.every((time) => +time.minute === 0);
780
+ schedule.shapes.minute === 'single';
778
781
  }
779
782
 
780
- // The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
781
- // "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
782
- // minute-0 confinement stays visible.
783
+ // The pinned clock minute in the genitive: "der Minute 9:02" for one hour,
784
+ // "der Minuten 9:00, 10:00 und 17:00" for several — the explicit minute so the
785
+ // single-fixed-minute confinement stays visible.
783
786
  function clockMinuteGenitive(
784
787
  times: {hour: number; minute: number}[],
785
788
  sep: string
@@ -940,18 +943,22 @@ function renderMinuteFrequency(
940
943
  if (plan.hours.kind === 'during') {
941
944
  // A bounded or uneven hour stride confines the minute cadence to its own
942
945
  // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
943
- // 20 Uhr").
946
+ // 20 Uhr"). That hour step is the sole hour authority, so an offset minute
947
+ // cadence drops its generic "jeder Stunde" (an every-hour scope conflicting
948
+ // with the step); a list of specific hours keeps it.
944
949
  const cadence = unevenHourCadence(schedule);
945
950
 
946
951
  return cadence ?
947
- base + ', ' + cadence :
952
+ stepClause(segment, UNITS.minute, '') + ', ' + cadence :
948
953
  base + ' ' + duringHours(schedule, plan.hours.times, sep);
949
954
  }
950
955
 
951
956
  if (plan.hours.kind === 'step') {
952
957
  // The plan carries a step only for a clean step (dividing the day):
953
- // confine the cadence to every Nth hour ("in jeder zweiten Stunde").
954
- return base + ' ' +
958
+ // confine the cadence to every Nth hour ("in jeder zweiten Stunde"). The
959
+ // hour step is the sole hour authority, so the minute cadence drops its
960
+ // generic "jeder Stunde".
961
+ return stepClause(segment, UNITS.minute, '') + ' ' +
955
962
  everyNthHour(stepSegment(schedule, 'hour'));
956
963
  }
957
964
 
@@ -1405,28 +1412,31 @@ function qualifier(schedule: Schedule, months: Months): string {
1405
1412
 
1406
1413
  // Plan kinds whose clause is a clock time: the qualifier leads them ("montags
1407
1414
  // um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
1408
- // minute-0 compose-seconds clause is anchored on a clock minute too, so the
1409
- // qualifier leads it ("montags jede Sekunde der Minute 9:00").
1415
+ // single-fixed-minute compose-seconds clause is anchored on a clock minute too,
1416
+ // so the qualifier leads it ("montags jede Sekunde der Minute 9:00").
1410
1417
  const LEADING_PLANS = new Set(['clockTimes']);
1411
1418
 
1412
1419
  // True when the leading qualifier should precede the clause: a clock-time
1413
- // plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
1420
+ // plan, or the single-fixed-minute compose-seconds clause that surfaces a clock
1421
+ // minute.
1414
1422
  function leadsQualifier(schedule: Schedule): boolean {
1415
- return LEADING_PLANS.has(schedule.plan.kind) || isComposeMinuteZero(schedule);
1423
+ return LEADING_PLANS.has(schedule.plan.kind) ||
1424
+ isComposeSingleMinute(schedule);
1416
1425
  }
1417
1426
 
1418
- // Whether the planned clause is the minute-0 compose-seconds confinement
1419
- // (a sub-minute second over a minute-0 clock-time rest).
1420
- function isComposeMinuteZero(schedule: Schedule): boolean {
1427
+ // Whether the planned clause is the single-fixed-minute compose-seconds
1428
+ // confinement (a sub-minute second over a single fixed minute's clock-time
1429
+ // rest).
1430
+ function isComposeSingleMinute(schedule: Schedule): boolean {
1421
1431
  return schedule.plan.kind === 'composeSeconds' &&
1422
- composeMinuteZero(schedule, schedule.plan);
1432
+ composeSingleMinute(schedule, schedule.plan);
1423
1433
  }
1424
1434
 
1425
1435
  // True when the clause is a bare daily clock-time list and so needs the
1426
1436
  // "täglich" frame to read as recurring, not a one-off: clockTimes always, the
1427
- // minute-0 compose-seconds clause (a recurring clock minute), and an uneven
1428
- // hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
1429
- // N Stunden"). A frequency clause already implies recurrence.
1437
+ // single-fixed-minute compose-seconds clause (a recurring clock minute), and an
1438
+ // uneven hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence
1439
+ // "alle N Stunden"). A frequency clause already implies recurrence.
1430
1440
  function needsDailyFrame(schedule: Schedule): boolean {
1431
1441
  // An hour cadence is a sub-daily frequency, not a daily clock-time list, so
1432
1442
  // it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
@@ -1435,7 +1445,7 @@ function needsDailyFrame(schedule: Schedule): boolean {
1435
1445
  return false;
1436
1446
  }
1437
1447
 
1438
- if (schedule.plan.kind === 'clockTimes' || isComposeMinuteZero(schedule)) {
1448
+ if (schedule.plan.kind === 'clockTimes' || isComposeSingleMinute(schedule)) {
1439
1449
  return true;
1440
1450
  }
1441
1451
 
@@ -1464,6 +1474,7 @@ function normalizeOptions(options?: Cronli5Options): Opts {
1464
1474
  return {
1465
1475
  ampm: typeof options.ampm === 'boolean' ? options.ampm : false,
1466
1476
  lenient: !!options.lenient,
1477
+ quartz: !!options.quartz,
1467
1478
  seconds: !!options.seconds,
1468
1479
  short: !!options.short,
1469
1480
  style,
@@ -137,6 +137,7 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
137
137
  return {
138
138
  ampm: typeof options.ampm === 'boolean' ? options.ampm : true,
139
139
  lenient: !!options.lenient,
140
+ quartz: !!options.quartz,
140
141
  seconds: !!options.seconds,
141
142
  short: !!options.short,
142
143
  style: resolveDialect(options.dialect),
@@ -580,8 +581,11 @@ function renderMinuteFrequency(schedule: Schedule,
580
581
  else if (plan.hours.kind === 'step') {
581
582
  // The plan carries a step only for a clean stride (dividing the day),
582
583
  // which confines the cadence to every Nth hour; a stepped hour field's
583
- // first segment is a step segment.
584
- 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 ? ' ' : ', ') +
585
589
  everyNthHour(stepSegment(schedule, 'hour'), opts);
586
590
  }
587
591
 
@@ -702,13 +706,14 @@ function renderMinuteSpanAcrossHourStep(schedule: Schedule,
702
706
  }
703
707
 
704
708
  // A minute list keeps the same cadence clause; only its lead differs. An
705
- // offset/uneven step the core enumerated to that list reads as a stride.
706
- const lead = plan.form === 'list' ?
709
+ // offset/uneven step the core enumerated to that list reads as a stride. The
710
+ // hour step scopes the hours, so the lead drops its generic "past the hour".
711
+ const lead = withoutHourAnchor(plan.form === 'list' ?
707
712
  strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
708
713
  opts) ??
709
714
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
710
715
  'minute', 'hour', opts) :
711
- minuteRangeLead(schedule.pattern.minute, opts);
716
+ minuteRangeLead(schedule.pattern.minute, opts));
712
717
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
713
718
  // the minute lead, not a wall of clock-time columns; an offset-clean step
714
719
  // keeps its existing per-step phrasing.
@@ -931,10 +936,14 @@ function renderCompactClockTimes(schedule: Schedule,
931
936
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
932
937
  'minute', 'hour', opts);
933
938
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
934
- // of clock-time columns.
939
+ // of clock-time columns. The hour step is the sole hour authority there, so
940
+ // the minute lead drops its generic "past the hour" (an every-hour scope that
941
+ // would conflict with the step); the clock-time branch keeps it, naming
942
+ // specific hours rather than a step.
935
943
  const cadence = unevenHourCadence(schedule, opts);
936
944
  const phrase = cadence ?
937
- minuteLead + ', ' + cadence + trailingQualifier(schedule, opts) :
945
+ withoutHourAnchor(minuteLead) + ', ' + cadence +
946
+ trailingQualifier(schedule, opts) :
938
947
  minuteLead +
939
948
  ', at ' +
940
949
  hourSegmentTimes(schedule, {minute: 0, second: null}, true, opts) +
@@ -1747,6 +1756,15 @@ function listPastThe(words: (string | number)[], unit: string, anchor: string,
1747
1756
  anchor;
1748
1757
  }
1749
1758
 
1759
+ // Strip the generic "past the hour" anchor from a minute-cadence lead. When the
1760
+ // hour field is restricted (a step or window), the hour clause is the sole hour
1761
+ // authority, so the cadence must not also assert "every hour" — "past the hour"
1762
+ // alongside a stepped/windowed hour reads as a conflicting every-hour scope. An
1763
+ // unrestricted hour keeps the anchor (it is the only hour statement there).
1764
+ function withoutHourAnchor(lead: string): string {
1765
+ return lead.replace(/ past the hour$/, '');
1766
+ }
1767
+
1750
1768
  // A clock time reads as a word ("noon"/"midnight") only at exact 12:00 or
1751
1769
  // 0:00 with no minute or second.
1752
1770
  function wordTime(hour: number | string, minute: number | string,
@@ -125,6 +125,7 @@ function normalizeOptions(options?: Cronli5Options): Opts {
125
125
  // 12-hour for Mexico/US); an explicit `{ampm}` option overrides it.
126
126
  ampm: typeof options.ampm === 'boolean' ? options.ampm : style.ampm,
127
127
  lenient: !!options.lenient,
128
+ quartz: !!options.quartz,
128
129
  seconds: !!options.seconds,
129
130
  short: !!options.short,
130
131
  style,
@@ -466,6 +467,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
466
467
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
467
468
  }
468
469
 
470
+ // Strip the generic "de cada hora" anchor from a minute-cadence lead. Under an
471
+ // hour STEP the hour clause is the sole hour authority, so the cadence must not
472
+ // also assert "de cada hora" — alongside a stepped hour it reads as a
473
+ // conflicting every-hour scope ("de cada hora, cada cuatro horas"). An hour
474
+ // WINDOW and an unrestricted hour keep the anchor (the window already names the
475
+ // hours; an open hour has no other hour statement).
476
+ function withoutHourAnchor(lead: string): string {
477
+ return lead.replace(/ de cada hora$/, '');
478
+ }
479
+
469
480
  // "cada minuto del 0 al 30". The standalone renderer adds "de cada hora";
470
481
  // when an hour qualifier follows ("..., a las 09:00", "..., cada dos
471
482
  // horas") it would contradict, so it is not baked in here.
@@ -593,8 +604,10 @@ function renderMinuteFrequency(
593
604
  }
594
605
  else if (plan.hours.kind === 'step') {
595
606
  // A clean stride is a confinement ("las horas pares", or the active-hour
596
- // list), never a juxtaposed cadence ("cada dos horas").
597
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
607
+ // list), never a juxtaposed cadence ("cada dos horas"). The hour step
608
+ // scopes the hours, so an offset cadence drops "de cada hora".
609
+ phrase = withoutHourAnchor(phrase) + ', ' +
610
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
598
611
  }
599
612
 
600
613
  return phrase + trailingQualifier(schedule, opts);
@@ -679,10 +692,10 @@ function renderMinuteSpanAcrossHourStep(
679
692
 
680
693
  // A minute list keeps the same cadence clause as the range; only its lead
681
694
  // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
682
- // 30").
683
- const lead = plan.form === 'list' ?
695
+ // 30"). The hour step scopes the hours, so the lead drops "de cada hora".
696
+ const lead = withoutHourAnchor(plan.form === 'list' ?
684
697
  minutesList(schedule, opts) :
685
- minuteRangeLead(schedule.pattern.minute);
698
+ minuteRangeLead(schedule.pattern.minute));
686
699
 
687
700
  return lead + ', ' +
688
701
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1270,10 +1283,13 @@ function renderCompactClockTimes(
1270
1283
  }
1271
1284
 
1272
1285
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1273
- // of clock-time columns.
1286
+ // of clock-time columns. That hour step is the sole hour authority, so the
1287
+ // minute lead drops its generic "de cada hora" (an every-hour scope that
1288
+ // would conflict with the step); the clock-time branch keeps it, naming
1289
+ // specific hours rather than a step.
1274
1290
  const cadence = unevenHourCadence(schedule, opts);
1275
1291
  const phrase = cadence ?
1276
- minutesList(schedule, opts) + ', ' + cadence +
1292
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1277
1293
  trailingQualifier(schedule, opts) :
1278
1294
  minutesList(schedule, opts) + ', ' +
1279
1295
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -200,6 +200,7 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
200
200
  return {
201
201
  ampm: false,
202
202
  lenient: !!options.lenient,
203
+ quartz: !!options.quartz,
203
204
  seconds: !!options.seconds,
204
205
  short: !!options.short,
205
206
  style: resolveDialect(options.dialect),
@@ -425,15 +426,15 @@ function renderComposeSeconds(
425
426
  return composeSecondsOverMinuteStep(schedule, plan.rest, opts);
426
427
  }
427
428
 
428
- // A sub-minute second with the minute pinned to 0 and a specific hour: the
429
- // clock-time rest would read "klo 9", dropping the pinned :00 and so the
430
- // one-minute confinement (60 fires in :00, not 3,600 across the hour). Bind
431
- // the seconds to the explicit clock minute with the "minuutin HH.00 aikana"
432
- // frame (an "of"/during form, never a range) and trail the day qualifier
433
- // ("joka sekunti minuutin 9.00 aikana, joka päivä").
434
- if (plan.rest.kind === 'clockTimes' &&
435
- plan.rest.times.every((time) => +time.minute === 0)) {
436
- return composeMinuteZero(schedule, plan.rest, opts);
429
+ // A second over a single fixed minute and a specific hour is a single fixed
430
+ // timestamp: the clock-time rest would float the seconds as a separate clause
431
+ // ("joka sekunti, joka päivä klo 9.02"), hiding that they belong to that one
432
+ // minute. Bind the seconds to the explicit clock minute with the "minuutin
433
+ // HH.MM aikana" frame (an "of"/during form, never a range) and trail the day
434
+ // qualifier ("joka sekunti minuutin 9.02 aikana, joka päivä") — the same
435
+ // fusion the minute-0 case ("minuutin 9.00 aikana") uses.
436
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.minute === 'single') {
437
+ return composeSingleMinute(schedule, plan.rest, opts);
437
438
  }
438
439
 
439
440
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
@@ -472,11 +473,13 @@ function isEveryOtherMinuteSeconds(
472
473
  return seg.startToken === '*' && seg.interval === 2;
473
474
  }
474
475
 
475
- // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
476
- // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
477
- // a range — a range would round-trip back to the whole hour) and trail the day
478
- // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
479
- function composeMinuteZero(
476
+ // The single-fixed-minute confinement: bind the seconds to the explicit clock
477
+ // minute(s) in the "minuutin/minuuttien HH.MM aikana" frame (an "of"/during
478
+ // form, never a range — a range would round-trip back to the whole hour) and
479
+ // trail the day qualifier ("joka sekunti minuutin 9.02 aikana, joka päivä").
480
+ // Minute 0 ("minuutin 9.00 aikana") is just this with the minute being 0; any
481
+ // single fixed minute fuses the same way.
482
+ function composeSingleMinute(
480
483
  schedule: Schedule,
481
484
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
482
485
  opts: NormalizedOptions
@@ -694,8 +697,10 @@ function renderMinuteFrequency(
694
697
  const cadence = unevenHourCadence(schedule, opts);
695
698
 
696
699
  if (cadence !== null) {
697
- return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
698
- trailingQualifier(schedule, opts);
700
+ // The hour step is the sole hour authority, so an offset minute cadence
701
+ // drops its generic "jokaisen tunnin" every-hour scope.
702
+ return withoutHourAnchor(stepCycle60(seg, units.minute, opts)) + ', ' +
703
+ cadence + trailingQualifier(schedule, opts);
699
704
  }
700
705
 
701
706
  // When the step renders as anchored ("kohdalla"), the per-hour windows
@@ -713,13 +718,16 @@ function renderMinuteFrequency(
713
718
  trailingQualifier(schedule, opts);
714
719
  }
715
720
 
716
- let phrase = stepCycle60(seg, units.minute, opts);
721
+ const phraseBase = stepCycle60(seg, units.minute, opts);
722
+ let phrase = phraseBase;
717
723
 
718
724
  if (plan.hours.kind === 'window') {
719
725
  phrase += ' ' + hourWindow(plan.hours, opts);
720
726
  }
721
727
  else if (plan.hours.kind === 'step') {
722
- phrase += ' ' +
728
+ // The hour step is the sole hour authority, so the minute cadence drops its
729
+ // generic "jokaisen tunnin" every-hour scope.
730
+ phrase = withoutHourAnchor(phraseBase) + ' ' +
723
731
  everyNthHour(stepSegment(schedule, 'hour'), opts);
724
732
  }
725
733
 
@@ -1166,6 +1174,16 @@ function stepCycle60(
1166
1174
  }, opts);
1167
1175
  }
1168
1176
 
1177
+ // Strip the generic "jokaisen tunnin" anchor from an offset minute-cadence
1178
+ // lead. When the hour field is a restricted step, the hour clause is the sole
1179
+ // hour authority, so the cadence must not also assert "jokaisen tunnin" (every
1180
+ // hour) — alongside a stepped hour it conflicts as an every-hour scope.
1181
+ // An unrestricted hour, and an hour WINDOW, keep the anchor (the window names
1182
+ // the hours without an every-hour-of-the-day conflict).
1183
+ function withoutHourAnchor(lead: string): string {
1184
+ return lead.replace(' ' + units.minute.anchor + ' ', ' ');
1185
+ }
1186
+
1169
1187
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
1170
1188
  // klo 1 alkaen".
1171
1189
  function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
@@ -138,6 +138,7 @@ function normalizeOptions(options?: Cronli5Options): Opts {
138
138
  // satisfied without the 12-hour machinery the es donor carried.
139
139
  ampm: false,
140
140
  lenient: !!options.lenient,
141
+ quartz: !!options.quartz,
141
142
  seconds: !!options.seconds,
142
143
  short: !!options.short,
143
144
  style,
@@ -475,6 +476,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
475
476
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de chaque heure';
476
477
  }
477
478
 
479
+ // Strip the generic "de chaque heure" anchor from a minute-cadence lead. Under
480
+ // an hour STEP the hour clause is the sole hour authority, so the cadence must
481
+ // not also assert "de chaque heure" — alongside a stepped hour it reads as a
482
+ // conflicting every-hour scope ("de chaque heure, toutes les quatre heures").
483
+ // An hour WINDOW and an unrestricted hour keep the anchor (the window already
484
+ // names the hours; an open hour has no other hour statement).
485
+ function withoutHourAnchor(lead: string): string {
486
+ return lead.replace(/ de chaque heure$/, '');
487
+ }
488
+
478
489
  // "chaque minute de 0 à 30". The standalone renderer adds "de chaque heure";
479
490
  // when an hour qualifier follows ("..., à 9 h", "..., toutes les deux heures")
480
491
  // it would contradict, so it is not baked in here.
@@ -549,8 +560,10 @@ function renderMinuteFrequency(
549
560
  }
550
561
  else if (plan.hours.kind === 'step') {
551
562
  // A clean stride is a confinement ("les heures paires", or the active-hour
552
- // list), never a juxtaposed cadence ("toutes les deux heures").
553
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
563
+ // list), never a juxtaposed cadence ("toutes les deux heures"). The hour
564
+ // step scopes the hours, so an offset cadence drops "de chaque heure".
565
+ phrase = withoutHourAnchor(phrase) + ', ' +
566
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
554
567
  }
555
568
 
556
569
  return phrase + trailingQualifier(schedule, opts);
@@ -634,10 +647,10 @@ function renderMinuteSpanAcrossHourStep(
634
647
 
635
648
  // A minute list keeps the same cadence clause as the range; only its lead
636
649
  // differs ("aux minutes 5 et 30 de chaque heure" vs "chaque minute de 0 à
637
- // 30").
638
- const lead = plan.form === 'list' ?
650
+ // 30"). The hour step scopes the hours, so the lead drops "de chaque heure".
651
+ const lead = withoutHourAnchor(plan.form === 'list' ?
639
652
  minutesList(schedule, opts) :
640
- minuteRangeLead(schedule.pattern.minute);
653
+ minuteRangeLead(schedule.pattern.minute));
641
654
 
642
655
  return lead + ', ' +
643
656
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -990,10 +1003,13 @@ function renderCompactClockTimes(
990
1003
  }
991
1004
 
992
1005
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
993
- // of clock-time columns.
1006
+ // of clock-time columns. That hour step is the sole hour authority, so the
1007
+ // minute lead drops its generic "de chaque heure" (an every-hour scope that
1008
+ // would conflict with the step); the clock-time branch keeps it, naming
1009
+ // specific hours rather than a step.
994
1010
  const cadence = unevenHourCadence(schedule, opts);
995
1011
  const phrase = cadence ?
996
- minutesList(schedule, opts) + ', ' + cadence +
1012
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
997
1013
  trailingQualifier(schedule, opts) :
998
1014
  minutesList(schedule, opts) + ', ' +
999
1015
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -269,6 +269,7 @@ function normalizeOptions(options?: Cronli5Options): Opts {
269
269
  // `{ampm}` option overrides it.
270
270
  ampm: typeof options.ampm === 'boolean' ? options.ampm : style.ampm,
271
271
  lenient: !!options.lenient,
272
+ quartz: !!options.quartz,
272
273
  seconds: !!options.seconds,
273
274
  short: !!options.short,
274
275
  style,
@@ -615,6 +616,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
615
616
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
616
617
  }
617
618
 
619
+ // Strip the generic "de cada hora" anchor from a minute-cadence lead. Under an
620
+ // hour STEP the hour clause is the sole hour authority, so the cadence must not
621
+ // also assert "de cada hora" — alongside a stepped hour it reads as a
622
+ // conflicting every-hour scope ("de cada hora, a cada quatro horas"). An hour
623
+ // WINDOW and an unrestricted hour keep the anchor (the window already names the
624
+ // hours; an open hour has no other hour statement).
625
+ function withoutHourAnchor(lead: string): string {
626
+ return lead.replace(/ de cada hora$/, '');
627
+ }
628
+
618
629
  // "a cada minuto do 0 ao 30". The standalone renderer adds "de cada hora";
619
630
  // when an hour qualifier follows ("..., às 09:00", "..., a cada duas horas")
620
631
  // it would contradict, so it is not baked in here.
@@ -745,8 +756,10 @@ function renderMinuteFrequency(
745
756
  }
746
757
  else if (plan.hours.kind === 'step') {
747
758
  // A clean stride is a confinement ("as horas pares", or the active-hour
748
- // list), never a juxtaposed cadence ("a cada duas horas").
749
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
759
+ // list), never a juxtaposed cadence ("a cada duas horas"). The hour step
760
+ // scopes the hours, so an offset cadence drops "de cada hora".
761
+ phrase = withoutHourAnchor(phrase) + ', ' +
762
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
750
763
  }
751
764
 
752
765
  return phrase + trailingQualifier(schedule, opts);
@@ -831,9 +844,10 @@ function renderMinuteSpanAcrossHourStep(
831
844
 
832
845
  // A minute list keeps the same cadence clause as the range; only its lead
833
846
  // differs ("nos minutos 5 e 30 de cada hora" vs "a cada minuto do 0 ao 30").
834
- const lead = plan.form === 'list' ?
847
+ // The hour step scopes the hours, so the lead drops "de cada hora".
848
+ const lead = withoutHourAnchor(plan.form === 'list' ?
835
849
  minutesList(schedule, opts) :
836
- minuteRangeLead(schedule.pattern.minute);
850
+ minuteRangeLead(schedule.pattern.minute));
837
851
 
838
852
  return lead + ', ' +
839
853
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1422,10 +1436,13 @@ function renderCompactClockTimes(
1422
1436
  }
1423
1437
 
1424
1438
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1425
- // of clock-time columns.
1439
+ // of clock-time columns. That hour step is the sole hour authority, so the
1440
+ // minute lead drops its generic "de cada hora" (an every-hour scope that
1441
+ // would conflict with the step); the clock-time branch keeps it, naming
1442
+ // specific hours rather than a step.
1426
1443
  const cadence = unevenHourCadence(schedule, opts);
1427
1444
  const phrase = cadence ?
1428
- minutesList(schedule, opts) + ', ' + cadence +
1445
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1429
1446
  trailingQualifier(schedule, opts) :
1430
1447
  minutesList(schedule, opts) + ', ' +
1431
1448
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);