cronli5 0.8.0 → 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.
@@ -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
 
@@ -703,13 +706,14 @@ function renderMinuteSpanAcrossHourStep(schedule: Schedule,
703
706
  }
704
707
 
705
708
  // 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' ?
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' ?
708
712
  strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
709
713
  opts) ??
710
714
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
711
715
  'minute', 'hour', opts) :
712
- minuteRangeLead(schedule.pattern.minute, opts);
716
+ minuteRangeLead(schedule.pattern.minute, opts));
713
717
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
714
718
  // the minute lead, not a wall of clock-time columns; an offset-clean step
715
719
  // keeps its existing per-step phrasing.
@@ -932,10 +936,14 @@ function renderCompactClockTimes(schedule: Schedule,
932
936
  listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
933
937
  'minute', 'hour', opts);
934
938
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
935
- // 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.
936
943
  const cadence = unevenHourCadence(schedule, opts);
937
944
  const phrase = cadence ?
938
- minuteLead + ', ' + cadence + trailingQualifier(schedule, opts) :
945
+ withoutHourAnchor(minuteLead) + ', ' + cadence +
946
+ trailingQualifier(schedule, opts) :
939
947
  minuteLead +
940
948
  ', at ' +
941
949
  hourSegmentTimes(schedule, {minute: 0, second: null}, true, opts) +
@@ -1748,6 +1756,15 @@ function listPastThe(words: (string | number)[], unit: string, anchor: string,
1748
1756
  anchor;
1749
1757
  }
1750
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
+
1751
1768
  // A clock time reads as a word ("noon"/"midnight") only at exact 12:00 or
1752
1769
  // 0:00 with no minute or second.
1753
1770
  function wordTime(hour: number | string, minute: number | string,
@@ -467,6 +467,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
467
467
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
468
468
  }
469
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
+
470
480
  // "cada minuto del 0 al 30". The standalone renderer adds "de cada hora";
471
481
  // when an hour qualifier follows ("..., a las 09:00", "..., cada dos
472
482
  // horas") it would contradict, so it is not baked in here.
@@ -594,8 +604,10 @@ function renderMinuteFrequency(
594
604
  }
595
605
  else if (plan.hours.kind === 'step') {
596
606
  // 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);
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);
599
611
  }
600
612
 
601
613
  return phrase + trailingQualifier(schedule, opts);
@@ -680,10 +692,10 @@ function renderMinuteSpanAcrossHourStep(
680
692
 
681
693
  // A minute list keeps the same cadence clause as the range; only its lead
682
694
  // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
683
- // 30").
684
- 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' ?
685
697
  minutesList(schedule, opts) :
686
- minuteRangeLead(schedule.pattern.minute);
698
+ minuteRangeLead(schedule.pattern.minute));
687
699
 
688
700
  return lead + ', ' +
689
701
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1271,10 +1283,13 @@ function renderCompactClockTimes(
1271
1283
  }
1272
1284
 
1273
1285
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1274
- // 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.
1275
1290
  const cadence = unevenHourCadence(schedule, opts);
1276
1291
  const phrase = cadence ?
1277
- minutesList(schedule, opts) + ', ' + cadence +
1292
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1278
1293
  trailingQualifier(schedule, opts) :
1279
1294
  minutesList(schedule, opts) + ', ' +
1280
1295
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -426,15 +426,15 @@ function renderComposeSeconds(
426
426
  return composeSecondsOverMinuteStep(schedule, plan.rest, opts);
427
427
  }
428
428
 
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);
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);
438
438
  }
439
439
 
440
440
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
@@ -473,11 +473,13 @@ function isEveryOtherMinuteSeconds(
473
473
  return seg.startToken === '*' && seg.interval === 2;
474
474
  }
475
475
 
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(
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(
481
483
  schedule: Schedule,
482
484
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
483
485
  opts: NormalizedOptions
@@ -695,8 +697,10 @@ function renderMinuteFrequency(
695
697
  const cadence = unevenHourCadence(schedule, opts);
696
698
 
697
699
  if (cadence !== null) {
698
- return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
699
- 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);
700
704
  }
701
705
 
702
706
  // When the step renders as anchored ("kohdalla"), the per-hour windows
@@ -714,13 +718,16 @@ function renderMinuteFrequency(
714
718
  trailingQualifier(schedule, opts);
715
719
  }
716
720
 
717
- let phrase = stepCycle60(seg, units.minute, opts);
721
+ const phraseBase = stepCycle60(seg, units.minute, opts);
722
+ let phrase = phraseBase;
718
723
 
719
724
  if (plan.hours.kind === 'window') {
720
725
  phrase += ' ' + hourWindow(plan.hours, opts);
721
726
  }
722
727
  else if (plan.hours.kind === 'step') {
723
- 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) + ' ' +
724
731
  everyNthHour(stepSegment(schedule, 'hour'), opts);
725
732
  }
726
733
 
@@ -1167,6 +1174,16 @@ function stepCycle60(
1167
1174
  }, opts);
1168
1175
  }
1169
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
+
1170
1187
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
1171
1188
  // klo 1 alkaen".
1172
1189
  function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
@@ -476,6 +476,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
476
476
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de chaque heure';
477
477
  }
478
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
+
479
489
  // "chaque minute de 0 à 30". The standalone renderer adds "de chaque heure";
480
490
  // when an hour qualifier follows ("..., à 9 h", "..., toutes les deux heures")
481
491
  // it would contradict, so it is not baked in here.
@@ -550,8 +560,10 @@ function renderMinuteFrequency(
550
560
  }
551
561
  else if (plan.hours.kind === 'step') {
552
562
  // A clean stride is a confinement ("les heures paires", or the active-hour
553
- // list), never a juxtaposed cadence ("toutes les deux heures").
554
- 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);
555
567
  }
556
568
 
557
569
  return phrase + trailingQualifier(schedule, opts);
@@ -635,10 +647,10 @@ function renderMinuteSpanAcrossHourStep(
635
647
 
636
648
  // A minute list keeps the same cadence clause as the range; only its lead
637
649
  // differs ("aux minutes 5 et 30 de chaque heure" vs "chaque minute de 0 à
638
- // 30").
639
- 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' ?
640
652
  minutesList(schedule, opts) :
641
- minuteRangeLead(schedule.pattern.minute);
653
+ minuteRangeLead(schedule.pattern.minute));
642
654
 
643
655
  return lead + ', ' +
644
656
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -991,10 +1003,13 @@ function renderCompactClockTimes(
991
1003
  }
992
1004
 
993
1005
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
994
- // 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.
995
1010
  const cadence = unevenHourCadence(schedule, opts);
996
1011
  const phrase = cadence ?
997
- minutesList(schedule, opts) + ', ' + cadence +
1012
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
998
1013
  trailingQualifier(schedule, opts) :
999
1014
  minutesList(schedule, opts) + ', ' +
1000
1015
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -616,6 +616,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
616
616
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
617
617
  }
618
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
+
619
629
  // "a cada minuto do 0 ao 30". The standalone renderer adds "de cada hora";
620
630
  // when an hour qualifier follows ("..., às 09:00", "..., a cada duas horas")
621
631
  // it would contradict, so it is not baked in here.
@@ -746,8 +756,10 @@ function renderMinuteFrequency(
746
756
  }
747
757
  else if (plan.hours.kind === 'step') {
748
758
  // A clean stride is a confinement ("as horas pares", or the active-hour
749
- // list), never a juxtaposed cadence ("a cada duas horas").
750
- 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);
751
763
  }
752
764
 
753
765
  return phrase + trailingQualifier(schedule, opts);
@@ -832,9 +844,10 @@ function renderMinuteSpanAcrossHourStep(
832
844
 
833
845
  // A minute list keeps the same cadence clause as the range; only its lead
834
846
  // differs ("nos minutos 5 e 30 de cada hora" vs "a cada minuto do 0 ao 30").
835
- 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' ?
836
849
  minutesList(schedule, opts) :
837
- minuteRangeLead(schedule.pattern.minute);
850
+ minuteRangeLead(schedule.pattern.minute));
838
851
 
839
852
  return lead + ', ' +
840
853
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1423,10 +1436,13 @@ function renderCompactClockTimes(
1423
1436
  }
1424
1437
 
1425
1438
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1426
- // 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.
1427
1443
  const cadence = unevenHourCadence(schedule, opts);
1428
1444
  const phrase = cadence ?
1429
- minutesList(schedule, opts) + ', ' + cadence +
1445
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1430
1446
  trailingQualifier(schedule, opts) :
1431
1447
  minutesList(schedule, opts) + ', ' +
1432
1448
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -327,6 +327,16 @@ function renderMinutePast(schedule: Schedule): string {
327
327
  return minuteHourClause(schedule);
328
328
  }
329
329
 
330
+ // Strip the generic "每小时" (every-hour) anchor that leads a minute clause.
331
+ // Under an hour STEP the hour cadence is the sole hour authority, so the minute
332
+ // clause must not also assert "每小时" — alongside a stepped hour ("每4小时…每小
333
+ // 时…") it reads as a conflicting every-hour scope. An hour WINDOW and an
334
+ // unrestricted hour keep "每小时" (the window already names the hours; an open
335
+ // hour has no other hour statement).
336
+ function withoutHourAnchor(clause: string): string {
337
+ return clause.replace(/^每小时/, '');
338
+ }
339
+
330
340
  // One hour segment as clock words by its form: a range is a span ("9点至20点"),
331
341
  // a single is one clock word ("22点"), a step keeps its fires enumerated as
332
342
  // clock words ("9点、11点、13点"). A range stated as a list element should read
@@ -383,7 +393,14 @@ function renderMinuteFrequency(schedule: Schedule, plan: PlanNode): string {
383
393
  const hourCad = unevenHourCadence(schedule);
384
394
 
385
395
  if (hourCad !== null) {
386
- return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + base;
396
+ // An hour STEP is the sole hour authority, so an offset minute cadence
397
+ // drops its leading "每小时" ("每4小时从5分起每10分钟"); a discrete hour
398
+ // list (during) keeps it. Only the step path reaches a non-null cadence
399
+ // here — an irregular list falls through to the enumerated frame below.
400
+ const minuteBase = hours.kind === 'step' ?
401
+ withoutHourAnchor(base) : base;
402
+
403
+ return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + minuteBase;
387
404
  }
388
405
  }
389
406
 
@@ -445,15 +462,17 @@ function renderMinuteSpanAcrossHourStep(
445
462
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
446
463
 
447
464
  // A minute list reads as the hour cadence plus the minute list ("每2小时,
448
- // 每小时0、25、50分"; offset "从1点起每2小时,每小时5分和30分"), the same compaction
449
- // the wildcard/range minute already uses, rather than the enumerated hours.
465
+ // 0、25、50分"; offset "从1点起每2小时,5分和30分"), the same compaction the
466
+ // wildcard/range minute already uses, rather than the enumerated hours. The
467
+ // hour cadence scopes the hours, so the minute clause drops its "每小时".
450
468
  if (form === 'list') {
451
- return hourCadencePhrase(schedule) + ',' + renderMinutePast(schedule);
469
+ return hourCadencePhrase(schedule) + ',' +
470
+ withoutHourAnchor(renderMinutePast(schedule));
452
471
  }
453
472
 
454
473
  const minuteTail = form === 'wildcard' ?
455
474
  '每分钟' :
456
- minuteHourClause(schedule) + ',每分钟';
475
+ withoutHourAnchor(minuteHourClause(schedule)) + ',每分钟';
457
476
 
458
477
  // An offset or non-tiling stride (2/6 fires at 2,8,14,20) reads as its
459
478
  // cadence ("从2点起每6小时"). A wildcard minute hangs off it with a comma; a
@@ -518,9 +537,13 @@ function renderCompactClockTimes(schedule: Schedule, plan: PlanNode): string {
518
537
  if (!compact.fold) {
519
538
  const hourCad = unevenHourCadence(schedule);
520
539
 
540
+ // A bounded/uneven hour step leads as the cadence and is the sole hour
541
+ // authority, so the minute clause drops its generic "每小时" every-hour
542
+ // scope; an enumerated hour list (hourCad null) names specific hours and
543
+ // keeps the anchor.
521
544
  return hourCad === null ?
522
545
  minuteHourClause(schedule) + ',在' + hourList(schedule) + tail :
523
- hourCad + ',' + minuteHourClause(schedule) + tail;
546
+ hourCad + ',' + withoutHourAnchor(minuteHourClause(schedule)) + tail;
524
547
  }
525
548
 
526
549
  // A single pinned minute past 0 leads with its clause; a pinned 0 folds into
@@ -870,6 +893,15 @@ function composeSecondsOnHour(
870
893
  return composeMinuteZeroClocks(schedule, sec);
871
894
  }
872
895
 
896
+ // A single fixed (non-zero) minute under enumerated clock times fuses the
897
+ // seconds onto the composed clock time the same way ("0点2分的每一秒").
898
+ const fusedSingleMinute =
899
+ composeSingleMinuteClocks(schedule, rest, sec, opts);
900
+
901
+ if (fusedSingleMinute !== null) {
902
+ return fusedSingleMinute;
903
+ }
904
+
873
905
  const restText = render(schedule, rest, opts);
874
906
  const secTail = clockRestCarriesSecond(rest) ? '' : sec;
875
907
 
@@ -886,6 +918,29 @@ function composeSecondsOnHour(
886
918
  return restText + secTail;
887
919
  }
888
920
 
921
+ // A single fixed (non-zero) minute under enumerated clock times: each clock
922
+ // point already names the minute ("0点2分", "9点5分和17点5分"), so bind the
923
+ // seconds to it with "的" — the same fusion the minute-0 ("0分的每一秒") and
924
+ // minute-step ("5、20…分的每一秒") cases use — rather than leaving a bare
925
+ // trailing "每秒" that floats as a second, unlinked adverbial. A single second
926
+ // already folded into each clock time ("9点5分30秒") is not re-appended. The
927
+ // compactClockTimes window form states its minute separately ("每小时5分") and
928
+ // keeps its own seconds clause, so it does not qualify (returns null). minute 0
929
+ // is handled by composeMinuteZeroClocks before this point.
930
+ function composeSingleMinuteClocks(
931
+ schedule: Schedule, rest: PlanNode, sec: string, opts: Opts
932
+ ): string | null {
933
+ if (rest.kind !== 'clockTimes' || schedule.shapes.minute !== 'single' ||
934
+ clockRestCarriesSecond(rest)) {
935
+ return null;
936
+ }
937
+
938
+ const core =
939
+ render(schedule, rest, opts) + minuteZeroSecondTail(schedule, sec);
940
+
941
+ return isDaily(schedule) ? '每天' + core : core;
942
+ }
943
+
889
944
  // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
890
945
  // bare clock word ("9点") would hide the :00 and leave the second dangling
891
946
  // ("…9点每秒"), reading as the whole hour. Fuse the seconds with the explicit
@@ -907,14 +962,24 @@ function composeMinuteZeroClocks(schedule: Schedule, sec: string): string {
907
962
  // midnight (凌晨0点) and other hours still need it to pin the minute.
908
963
  return hour === 12 ? '正午' : hourWord(hour) + '0分';
909
964
  });
910
- // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
911
- // single minute, not every minute), so the stride here drops it.
965
+ const core = joinAnd(clocks) + minuteZeroSecondTail(schedule, sec);
966
+
967
+ return isDaily(schedule) ? '每天' + core : core;
968
+ }
969
+
970
+ // The "的"-fused second tail for a clock time that already names a single pinned
971
+ // minute ("…的每一秒" for a wildcard second, else "…的" + the second's clause).
972
+ // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
973
+ // single minute, not every minute), so a stride here drops it.
974
+ function minuteZeroSecondTail(schedule: Schedule, sec: string): string {
975
+ if (sec === '每秒') {
976
+ return '的每一秒';
977
+ }
978
+
912
979
  const nested =
913
980
  strideFromSegments(segmentsOf(schedule, 'second'), '秒', '秒', '');
914
- const tail = sec === '每秒' ? '的每一秒' : '的' + (nested ?? sec);
915
- const core = joinAnd(clocks) + tail;
916
981
 
917
- return isDaily(schedule) ? '每天' + core : core;
982
+ return '' + (nested ?? sec);
918
983
  }
919
984
 
920
985
  // Whether the hour field is a range — or a list whose segments include a
@@ -1010,7 +1075,10 @@ function composeSecondsListed(schedule: Schedule): string {
1010
1075
  const hourCad = unevenHourCadence(schedule);
1011
1076
 
1012
1077
  if (hourCad !== null) {
1013
- return hourCad + ',' + minutes + ',' + sec;
1078
+ // An hour STEP cadence is the sole hour authority, so the minute clause
1079
+ // drops its "每小时" ("每2小时,0至30分,每秒"); a discrete hour list keeps it
1080
+ // (it falls through to the hourFrame branch below with a null cadence).
1081
+ return hourCad + ',' + withoutHourAnchor(minutes) + ',' + sec;
1014
1082
  }
1015
1083
 
1016
1084
  return hourFrame(schedule) + minutes + ',' + sec;