cronli5 0.8.3 → 0.8.5

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.
package/dist/lang/pt.js CHANGED
@@ -206,20 +206,6 @@ function weekdayFeminine(number) {
206
206
  }
207
207
  var nthWeekdayMasculine = [null, "primeiro", "segundo", "terceiro", "quarto", "quinto"];
208
208
  var nthWeekdayFeminine = [null, "primeira", "segunda", "terceira", "quarta", "quinta"];
209
- var stepOrdinals = {
210
- 3: "terceiro",
211
- 4: "quarto",
212
- 5: "quinto",
213
- 6: "sexto",
214
- 7: "s\xE9timo",
215
- 8: "oitavo",
216
- 9: "nono",
217
- 10: "d\xE9cimo",
218
- 12: "d\xE9cimo segundo",
219
- 15: "d\xE9cimo quinto",
220
- 20: "vig\xE9simo",
221
- 30: "trig\xE9simo"
222
- };
223
209
  function noonMidnightArticle(phrase) {
224
210
  if (phrase === "meio-dia") {
225
211
  return "o";
@@ -325,6 +311,9 @@ function renderSecondsWithinMinute(schedule, plan, opts) {
325
311
  if (plan.singleSecond) {
326
312
  return "no minuto " + minuteField + " e no segundo " + schedule.pattern.second + " de cada hora" + trailingQualifier(schedule, opts);
327
313
  }
314
+ if (secondsConfinesMinute(schedule)) {
315
+ return secondsBareLead(schedule) + " " + confinedMinutePhrase(schedule) + trailingQualifier(schedule, opts);
316
+ }
328
317
  return secondsLeadClause(schedule, opts) + ", no minuto " + minuteField + " de cada hora" + trailingQualifier(schedule, opts);
329
318
  }
330
319
  function secondsListAtClock(schedule, rest, opts) {
@@ -358,19 +347,68 @@ function minuteStride(schedule) {
358
347
  const values = singleValues(segmentsOf(schedule, "minute"));
359
348
  return values && arithmeticStep(values);
360
349
  }
361
- function minuteStepConfinement(schedule, stride, opts) {
362
- const ordinal = stepOrdinals[stride.interval];
363
- const head = ordinal ? "no " + ordinal + " minuto" : "a cada " + numero(stride.interval, opts) + " minutos";
364
- const tail = renderStride({ ...stride, cycle: 60 }, {
365
- bare: () => "",
366
- offset: () => " a partir do minuto " + stride.start,
367
- bounded: () => " do minuto " + stride.start + " ao " + stride.last
368
- });
369
- return secondsLeadClause(schedule, opts) + " " + head + tail + " de cada hora" + trailingQualifier(schedule, opts);
350
+ function steppedMinuteConfinement(schedule, plan, lead, opts) {
351
+ return lead + ", " + render(schedule, plan.rest, opts) + trailingQualifier(schedule, opts);
370
352
  }
371
353
  function isSteppedMinuteSeconds(schedule, plan) {
372
354
  return (plan.rest.kind === "minuteFrequency" || plan.rest.kind === "multipleMinutes") && (schedule.shapes.second === "wildcard" || schedule.shapes.second === "step") && schedule.shapes.hour === "wildcard" && schedule.pattern.minute !== "*/2" && minuteStride(schedule) !== null;
373
355
  }
356
+ function secondsBareLead(schedule) {
357
+ const secondField = schedule.pattern.second;
358
+ const shape = schedule.shapes.second;
359
+ if (shape === "range") {
360
+ const bounds = secondField.split("-");
361
+ return "a cada segundo do " + bounds[0] + " ao " + bounds[1];
362
+ }
363
+ if (shape === "single") {
364
+ return "no segundo " + secondField;
365
+ }
366
+ return "nos segundos " + joinList(segmentWords(segmentsOf(schedule, "second")));
367
+ }
368
+ function confinedMinutePhrase(schedule) {
369
+ if (schedule.shapes.minute === "range") {
370
+ const range = minuteRangeLead(schedule.pattern.minute).replace(/^a /u, "");
371
+ return "de " + range + " de cada hora";
372
+ }
373
+ if (schedule.shapes.minute === "list") {
374
+ return "dos minutos " + joinList(segmentWords(segmentsOf(schedule, "minute"))) + " de cada hora";
375
+ }
376
+ return "do minuto " + schedule.pattern.minute + " de cada hora";
377
+ }
378
+ function secondsConfinesMinute(schedule) {
379
+ const { second, minute, hour } = schedule.shapes;
380
+ if (second === "list") {
381
+ const values = singleValues(segmentsOf(schedule, "second"));
382
+ if (values && arithmeticStep(values)) {
383
+ return false;
384
+ }
385
+ }
386
+ const clockPoint = second === "single" || second === "range" || second === "list";
387
+ return clockPoint && minute !== "wildcard" && hour === "wildcard" && !(second === "single" && minute === "single");
388
+ }
389
+ function minuteConfinementRender(plan, schedule, opts) {
390
+ if (isSteppedMinuteSeconds(schedule, plan)) {
391
+ return steppedMinuteConfinement(
392
+ schedule,
393
+ plan,
394
+ secondsLeadClause(schedule, opts),
395
+ opts
396
+ );
397
+ }
398
+ const minuteRest = plan.rest.kind === "minuteFrequency" || plan.rest.kind === "multipleMinutes" || plan.rest.kind === "rangeOfMinutes";
399
+ if (minuteRest && secondsConfinesMinute(schedule)) {
400
+ if (minuteStride(schedule) && schedule.pattern.minute !== "*/2") {
401
+ return steppedMinuteConfinement(
402
+ schedule,
403
+ plan,
404
+ secondsBareLead(schedule),
405
+ opts
406
+ );
407
+ }
408
+ return secondsBareLead(schedule) + " " + confinedMinutePhrase(schedule) + trailingQualifier(schedule, opts);
409
+ }
410
+ return null;
411
+ }
374
412
  function renderComposeSeconds(schedule, plan, opts) {
375
413
  const hourCad = composeHourCadence(schedule, plan, opts);
376
414
  if (hourCad !== null) {
@@ -389,8 +427,9 @@ function renderComposeSeconds(schedule, plan, opts) {
389
427
  const cadence = "a cada " + numero(stepSegment(schedule, "second").interval, opts) + " segundos do minuto " + schedule.pattern.minute;
390
428
  return dayFrame + ", " + window + ", " + cadence;
391
429
  }
392
- if (isSteppedMinuteSeconds(schedule, plan)) {
393
- return minuteStepConfinement(schedule, minuteStride(schedule), opts);
430
+ const confined = minuteConfinementRender(plan, schedule, opts);
431
+ if (confined !== null) {
432
+ return confined;
394
433
  }
395
434
  if (isEveryOtherMinuteSeconds(schedule, plan)) {
396
435
  const rest = render(schedule, plan.rest, opts).replace(/^a /u, "");
package/dist/lang/zh.cjs CHANGED
@@ -573,8 +573,8 @@ function secondClause(schedule) {
573
573
  return "\u6BCF\u79D2";
574
574
  }
575
575
  const first = segs[0];
576
- if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
577
- return cadence(first.interval, UNITS.second);
576
+ if (segs.length === 1 && first.kind === "step") {
577
+ return stepClause(first, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F");
578
578
  }
579
579
  return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u7B2C" + valueText(segs) + "\u79D2";
580
580
  }
@@ -670,6 +670,9 @@ function composeSecondsCadence(schedule) {
670
670
  return "\u6BCF\u5076\u6570\u5206\u949F\u7684\u6BCF\u4E00\u79D2";
671
671
  }
672
672
  }
673
+ if (!secondIsCadence(schedule) && !secondIsStride(schedule)) {
674
+ return minuteClause(schedule) + "\u7684" + sec;
675
+ }
673
676
  return sec + "\uFF0C" + minuteClause(schedule);
674
677
  }
675
678
  return hourFrame(schedule) + tail;
@@ -683,7 +686,7 @@ function composeSecondsListed(schedule) {
683
686
  return hourWord(hourFires(schedule)[0]) + minuteCad + "\u7684\u6BCF\u4E00\u79D2";
684
687
  }
685
688
  if (schedule.shapes.hour === "wildcard") {
686
- return secondIsCadence(schedule) ? minutes + confinedSecondTail(sec) : minutes + "\uFF0C" + sec;
689
+ return secondIsStride(schedule) ? minutes + "\uFF0C" + sec : minutes + confinedSecondTail(sec);
687
690
  }
688
691
  const hourCad = unevenHourCadence(schedule);
689
692
  if (hourCad !== null) {
@@ -701,6 +704,13 @@ function isMinuteStride(schedule) {
701
704
  function secondIsCadence(schedule) {
702
705
  return schedule.pattern.second === "*" || schedule.shapes.second === "step";
703
706
  }
707
+ function secondIsStride(schedule) {
708
+ if (schedule.shapes.second !== "list") {
709
+ return false;
710
+ }
711
+ const values = singleValues(segmentsOf(schedule, "second"));
712
+ return values !== null && arithmeticStep(values) !== null;
713
+ }
704
714
  function confinedSecondTail(sec) {
705
715
  return sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
706
716
  }
package/dist/lang/zh.js CHANGED
@@ -547,8 +547,8 @@ function secondClause(schedule) {
547
547
  return "\u6BCF\u79D2";
548
548
  }
549
549
  const first = segs[0];
550
- if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
551
- return cadence(first.interval, UNITS.second);
550
+ if (segs.length === 1 && first.kind === "step") {
551
+ return stepClause(first, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F");
552
552
  }
553
553
  return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u7B2C" + valueText(segs) + "\u79D2";
554
554
  }
@@ -644,6 +644,9 @@ function composeSecondsCadence(schedule) {
644
644
  return "\u6BCF\u5076\u6570\u5206\u949F\u7684\u6BCF\u4E00\u79D2";
645
645
  }
646
646
  }
647
+ if (!secondIsCadence(schedule) && !secondIsStride(schedule)) {
648
+ return minuteClause(schedule) + "\u7684" + sec;
649
+ }
647
650
  return sec + "\uFF0C" + minuteClause(schedule);
648
651
  }
649
652
  return hourFrame(schedule) + tail;
@@ -657,7 +660,7 @@ function composeSecondsListed(schedule) {
657
660
  return hourWord(hourFires(schedule)[0]) + minuteCad + "\u7684\u6BCF\u4E00\u79D2";
658
661
  }
659
662
  if (schedule.shapes.hour === "wildcard") {
660
- return secondIsCadence(schedule) ? minutes + confinedSecondTail(sec) : minutes + "\uFF0C" + sec;
663
+ return secondIsStride(schedule) ? minutes + "\uFF0C" + sec : minutes + confinedSecondTail(sec);
661
664
  }
662
665
  const hourCad = unevenHourCadence(schedule);
663
666
  if (hourCad !== null) {
@@ -675,6 +678,13 @@ function isMinuteStride(schedule) {
675
678
  function secondIsCadence(schedule) {
676
679
  return schedule.pattern.second === "*" || schedule.shapes.second === "step";
677
680
  }
681
+ function secondIsStride(schedule) {
682
+ if (schedule.shapes.second !== "list") {
683
+ return false;
684
+ }
685
+ const values = singleValues(segmentsOf(schedule, "second"));
686
+ return values !== null && arithmeticStep(values) !== null;
687
+ }
678
688
  function confinedSecondTail(sec) {
679
689
  return sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
680
690
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -668,7 +668,17 @@ function renderSecondsWithinMinute(
668
668
  schedule.pattern.second + ' jeder Stunde';
669
669
  }
670
670
 
671
- return secondsLead(schedule) + ', in Minute ' + schedule.pattern.minute +
671
+ // A second LIST or RANGE under a single minute confines that minute in the
672
+ // genitive ("in den Sekunden 5 und 10 der Minute 30 jeder Stunde"), never the
673
+ // comma juxtaposition; a STEP second is a cadence and keeps its own lead.
674
+ if (secondsConfinesMinute(schedule)) {
675
+ return secondsLead(schedule) + ' ' + confinedMinutePhrase(schedule);
676
+ }
677
+
678
+ // A cadence/stepped second leads straight into the locative "in Minute …"
679
+ // with NO comma ("alle 15 Sekunden in Minute 30 jeder Stunde"); the locative
680
+ // binds the two specs, matching the no-comma list/single confinement.
681
+ return secondsLead(schedule) + ' in Minute ' + schedule.pattern.minute +
672
682
  ' jeder Stunde';
673
683
  }
674
684
 
@@ -786,6 +796,96 @@ function isSteppedMinuteSeconds(
786
796
  minuteStride(schedule) !== null;
787
797
  }
788
798
 
799
+ // The CONFINED-minute phrase in the genitive that a clock-point second attaches
800
+ // to ("jeder sechsten Minute ab Minute 4 jeder Stunde", "der Minuten 0, 15 und
801
+ // 30 jeder Stunde", "der Minute 30 jeder Stunde"). A stepped minute reuses the
802
+ // ordinal cadence; a list, range, or single names the minute(s) in the genitive
803
+ // — so the seconds clause's bare lead never stacks a redundant "jeder Minute".
804
+ function confinedMinutePhrase(schedule: Schedule): string {
805
+ const stride = minuteStride(schedule);
806
+
807
+ if (stride && schedule.pattern.minute !== '*/2') {
808
+ const ordinal = minuteStepOrdinals[stride.interval];
809
+ const head = ordinal ?
810
+ 'jeder ' + ordinal + ' Minute' :
811
+ 'alle ' + stride.interval + ' Minuten';
812
+ const tail = chooseStride({...stride, cycle: 60}, {
813
+ bare: () => '',
814
+ offset: () => ' ab Minute ' + stride.start,
815
+ bounded: () => ' von Minute ' + stride.start + ' bis ' + stride.last
816
+ });
817
+
818
+ return head + tail + ' jeder Stunde';
819
+ }
820
+
821
+ const genitive = schedule.shapes.minute === 'single' ?
822
+ 'der Minute ' + schedule.pattern.minute :
823
+ 'der Minuten ' + joinList(fieldValues(schedule, 'minute'));
824
+
825
+ return genitive + ' jeder Stunde';
826
+ }
827
+
828
+ // The minute-confinement rendering for a compose-seconds plan, or null when the
829
+ // plan is not one. A CADENCE second over a stepped minute uses the ordinal
830
+ // cadence form; a CLOCK-POINT second (list/range/single) over any restricted
831
+ // minute uses the genitive form. Both bind the second beneath the minute
832
+ // instead of juxtaposing the two behind a comma. Folded into one helper so
833
+ // `renderComposeSeconds` carries a single branch.
834
+ function minuteConfinementRender(
835
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>, schedule: Schedule
836
+ ): string | null {
837
+ if (isSteppedMinuteSeconds(schedule, plan)) {
838
+ return minuteStepConfinement(schedule, minuteStride(schedule)!);
839
+ }
840
+
841
+ const minuteRest = plan.rest.kind === 'minuteFrequency' ||
842
+ plan.rest.kind === 'multipleMinutes' ||
843
+ plan.rest.kind === 'rangeOfMinutes';
844
+
845
+ if (minuteRest && secondsConfinesMinute(schedule)) {
846
+ return secondsLead(schedule) + ' ' + confinedMinutePhrase(schedule);
847
+ }
848
+
849
+ return null;
850
+ }
851
+
852
+ // Whether a clock-point second (list, range, or single) sits under a restricted
853
+ // minute and a wildcard hour — the shape that must CONFINE the minute in the
854
+ // genitive rather than juxtapose it behind a comma (two independent schedules).
855
+ // A second LIST the core enumerated from a step (`3/2`) is really a stride
856
+ // cadence and stays out. The single-second + single-minute pair folds into one
857
+ // coherent clock point ("in Minute 5 und Sekunde 30 jeder Stunde") and is
858
+ // excluded.
859
+ function secondsConfinesMinute(schedule: Schedule): boolean {
860
+ const {second, minute, hour} = schedule.shapes;
861
+
862
+ if (second === 'list') {
863
+ const values = singleValues(segmentsOf(schedule, 'second'));
864
+
865
+ if (values && arithmeticStep(values)) {
866
+ return false;
867
+ }
868
+ }
869
+
870
+ const clockPoint = second === 'single' || second === 'range' ||
871
+ second === 'list';
872
+
873
+ return clockPoint && minute !== 'wildcard' && hour === 'wildcard' &&
874
+ !(second === 'single' && minute === 'single');
875
+ }
876
+
877
+ // Whether a compose-seconds plan is a cadence/stepped second under a minute
878
+ // LIST or SINGLE and a wildcard hour — the shape that leads into the locative
879
+ // "in …" minute phrase with no comma. A restricted/cadence hour keeps the
880
+ // comma, so it does not qualify.
881
+ function isLocativeMinuteConfinement(
882
+ schedule: Schedule,
883
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
884
+ ): boolean {
885
+ return (plan.rest.kind === 'multipleMinutes' ||
886
+ plan.rest.kind === 'singleMinute') && schedule.shapes.hour === 'wildcard';
887
+ }
888
+
789
889
  function renderComposeSeconds(
790
890
  schedule: Schedule,
791
891
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -818,12 +918,15 @@ function renderComposeSeconds(
818
918
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
819
919
  }
820
920
 
821
- // A stepped minute under a wildcard/stepped second + wildcard hour confines
822
- // the second cadence to the ordinal minute cadence ("jede Sekunde in jeder
823
- // sechsten Minute ab Minute 4 jeder Stunde"), never the comma juxtaposition
824
- // that reads as two independent cadences.
825
- if (isSteppedMinuteSeconds(schedule, plan)) {
826
- return minuteStepConfinement(schedule, minuteStride(schedule)!);
921
+ // A second confines the minute restriction (open hour), never the comma
922
+ // juxtaposition that reads as two independent cadences: a CADENCE second over
923
+ // a stepped minute uses the ordinal-cadence form ("jede Sekunde in jeder
924
+ // sechsten Minute …"); a CLOCK-POINT second uses the genitive form ("in den
925
+ // Sekunden 5, 10 und 15 jeder sechsten Minute …").
926
+ const confined = minuteConfinementRender(plan, schedule);
927
+
928
+ if (confined !== null) {
929
+ return confined;
827
930
  }
828
931
 
829
932
  // A wildcard second under a minute */2 with a wildcard hour binds in the
@@ -832,6 +935,15 @@ function renderComposeSeconds(
832
935
  return secondsLead(schedule) + ' jeder zweiten Minute';
833
936
  }
834
937
 
938
+ // A cadence/stepped second under a minute LIST or SINGLE and a wildcard hour
939
+ // leads straight into the locative minute phrase with NO comma ("jede Sekunde
940
+ // in den Minuten 0, 15 und 30 jeder Stunde"). The locative "in" already binds
941
+ // the two specs; the comma read as two independent specifications and is
942
+ // inconsistent with the no-comma stepped-minute and list-tier confinements.
943
+ if (isLocativeMinuteConfinement(schedule, plan)) {
944
+ return secondsLead(schedule) + ' ' + render(schedule, plan.rest, opts);
945
+ }
946
+
835
947
  // A compact clock-time rest folds a meaningful SINGLE second into its own
836
948
  // leading clause, so the composer must not prepend a second lead that would
837
949
  // double it. A wildcard or stepped second is not folded there (no
@@ -1102,15 +1102,69 @@ function isCadenceField(token: string): boolean {
1102
1102
  token.startsWith('*/') && token.indexOf('-') === -1;
1103
1103
  }
1104
1104
 
1105
+ // Whether the second field leads the confinement frame as a clean cadence. A
1106
+ // wildcard ("every second") and a clean `*/n` step both lead via
1107
+ // `isCadenceField`; an OPEN OFFSET step (`m/n`) is the SAME cadence, only named
1108
+ // from its offset ("every six seconds from five seconds past the minute"), so
1109
+ // it leads the SAME confinement rather than juxtaposing the minute restriction
1110
+ // behind a comma — whether the offset is clean from the top (`0/n`) or not
1111
+ // (`5/n`). A bounded step (`a-b/n`, a windowed set) is not an open cadence and
1112
+ // keeps its existing form.
1113
+ function secondLeadsCadence(schedule: Schedule): boolean {
1114
+ if (isCadenceField(schedule.pattern.second)) {
1115
+ return true;
1116
+ }
1117
+
1118
+ if (schedule.shapes.second !== 'step') {
1119
+ return false;
1120
+ }
1121
+
1122
+ // Reached only under a stepped second the `isCadenceField` guard did not
1123
+ // already admit, so its `*/n` clean-cadence forms are gone and the remaining
1124
+ // open form is the offset step `m/n` (`0/n` or non-zero). A bounded step
1125
+ // `a-b/n` is a windowed set, not a cadence, and stays out.
1126
+ return isOpenStep(schedule.pattern.second);
1127
+ }
1128
+
1129
+ // Whether the second leads the confinement frame as a CLOCK-POINT clause (a
1130
+ // list, range, or single second), as opposed to a cadence. A clock-point second
1131
+ // under a minute restriction confines that restriction exactly as the cadence
1132
+ // does ("at 5, 10, and 15 seconds past the minute during every sixth minute …")
1133
+ // rather than juxtaposing it behind a comma, which reads as two independent
1134
+ // schedules. The single-second + single-minute pair is excluded: it folds into
1135
+ // one coherent clock point ("30 minutes and 15 seconds past the hour"), not a
1136
+ // juxtaposition, so it keeps that fold. The confinement only applies where the
1137
+ // minute is the restriction and the hour is open; `confinementEligible` gates
1138
+ // the rest (a restricted hour folds into a clock time, left to that renderer).
1139
+ function secondLeadsClockPoint(schedule: Schedule): boolean {
1140
+ // Only a MEANINGFUL second leads a clause: the two seconds-bearing plans the
1141
+ // core chooses for a real second. A 5-field pattern (or an explicit `0`
1142
+ // second) carries no seconds clause — its plan is the minute's own — so it is
1143
+ // not confined here, which would otherwise prepend "at zero seconds …".
1144
+ if (schedule.plan.kind !== 'composeSeconds' &&
1145
+ schedule.plan.kind !== 'secondsWithinMinute') {
1146
+ return false;
1147
+ }
1148
+
1149
+ const {second, minute, hour} = schedule.shapes;
1150
+ const clockPoint = second === 'single' || second === 'range' ||
1151
+ second === 'list';
1152
+ const minuteRestricted = minute !== 'wildcard';
1153
+
1154
+ return clockPoint && minuteRestricted && hour === 'wildcard' &&
1155
+ !(second === 'single' && minute === 'single');
1156
+ }
1157
+
1105
1158
  // The leading cadence and whether the second is the leading field, or null when
1106
1159
  // the pattern has no cadence lead (the finest restricted field is a clock-point
1107
- // single/range/list). The seconds lead when restricted as a cadence; otherwise
1108
- // the minute leads when the second is a plain :00 and the minute is a cadence.
1160
+ // single/range/list). The seconds lead when restricted as a cadence, or as a
1161
+ // clock-point clause that confines a minute restriction; otherwise the minute
1162
+ // leads when the second is a plain :00 and the minute is a cadence.
1109
1163
  function leadingCadence(schedule: Schedule, opts: NormalizedOptions):
1110
1164
  {text: string; secondLead: boolean} | null {
1111
1165
  const {second, minute} = schedule.pattern;
1112
1166
 
1113
- if (isCadenceField(second)) {
1167
+ if (secondLeadsCadence(schedule) || secondLeadsClockPoint(schedule)) {
1114
1168
  return {secondLead: true, text: secondsClause(schedule, 'minute', opts)};
1115
1169
  }
1116
1170
 
@@ -215,7 +215,19 @@ function renderSecondsWithinMinute(
215
215
  trailingQualifier(schedule, opts);
216
216
  }
217
217
 
218
- return secondsLeadClause(schedule, opts) + ', en el minuto ' + minuteField +
218
+ // A second LIST or RANGE under a single minute confines that minute with the
219
+ // genitive "de" ("en los segundos 5 y 10 del minuto 30 de cada hora"), never
220
+ // the comma juxtaposition that reads as two independent schedules. A STEP
221
+ // second is a cadence ("cada 15 segundos") and keeps its own lead.
222
+ if (secondsConfinesMinute(schedule)) {
223
+ return secondsBareLead(schedule) + ' ' +
224
+ confinedMinutePhrase(schedule, opts) + trailingQualifier(schedule, opts);
225
+ }
226
+
227
+ // A cadence/stepped second leads straight into the locative "en el minuto …"
228
+ // with NO comma ("cada 15 segundos en el minuto 30 de cada hora"); the
229
+ // locative binds the two specs, matching the no-comma list/single form.
230
+ return secondsLeadClause(schedule, opts) + ' en el minuto ' + minuteField +
219
231
  ' de cada hora' + trailingQualifier(schedule, opts);
220
232
  }
221
233
 
@@ -345,6 +357,136 @@ function isSteppedMinuteSeconds(
345
357
  minuteStride(schedule) !== null;
346
358
  }
347
359
 
360
+ // The leading seconds words for a clock-point second, WITHOUT the trailing "de
361
+ // cada minuto" anchor: a confined second attaches to the CONFINED minute ("de
362
+ // cada sexto minuto…"), so the generic minute anchor would be redundant. The
363
+ // list/range/single forms mirror `secondsClause` minus that anchor.
364
+ function secondsBareLead(schedule: Schedule): string {
365
+ const secondField = schedule.pattern.second;
366
+ const shape = schedule.shapes.second;
367
+
368
+ if (shape === 'range') {
369
+ const bounds = secondField.split('-');
370
+
371
+ return 'cada segundo del ' + bounds[0] + ' al ' + bounds[1];
372
+ }
373
+
374
+ if (shape === 'single') {
375
+ return 'en el segundo ' + secondField;
376
+ }
377
+
378
+ return 'en los segundos ' +
379
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
380
+ }
381
+
382
+ // The CONFINED-minute genitive phrase a clock-point second attaches to, with
383
+ // its leading connector folded in ("de cada sexto minuto a partir del minuto 4
384
+ // de cada hora", "de los minutos 0, 15 y 30 de cada hora", "del minuto 30 de
385
+ // cada hora"). A stepped minute reuses the ordinal cadence form; a list, range,
386
+ // or single names the minute(s) directly. This is the seconds clause's anchor,
387
+ // so the generic "de cada minuto" is never stacked alongside it.
388
+ function confinedMinutePhrase(schedule: Schedule, opts: Opts): string {
389
+ const stride = minuteStride(schedule);
390
+
391
+ if (stride && schedule.pattern.minute !== '*/2') {
392
+ const ordinal = stepOrdinals[stride.interval];
393
+ const head = ordinal ?
394
+ 'cada ' + ordinal + ' minuto' :
395
+ 'cada ' + numero(stride.interval, opts) + ' minutos';
396
+ const tail = chooseStride({...stride, cycle: 60}, {
397
+ bare: () => '',
398
+ offset: () => ' a partir del minuto ' + stride.start,
399
+ bounded: () => ' del minuto ' + stride.start + ' al ' + stride.last
400
+ });
401
+
402
+ return 'de ' + head + tail + ' de cada hora';
403
+ }
404
+
405
+ if (schedule.shapes.minute === 'range') {
406
+ return 'de ' + minuteRangeLead(schedule.pattern.minute) + ' de cada hora';
407
+ }
408
+
409
+ if (schedule.shapes.minute === 'list') {
410
+ return 'de los minutos ' +
411
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
412
+ }
413
+
414
+ // A single pinned minute: "del minuto 30 de cada hora".
415
+ return 'del minuto ' + schedule.pattern.minute + ' de cada hora';
416
+ }
417
+
418
+ // Whether a clock-point second (list, range, or single) sits under a restricted
419
+ // minute and a wildcard hour — the shape that must CONFINE the minute with the
420
+ // genitive "de" rather than juxtapose it behind a comma (two independent
421
+ // schedules). The single-second + single-minute pair folds into one coherent
422
+ // clock point ("en el minuto 5 y el segundo 30 de cada hora") and is excluded.
423
+ // The minute-confinement rendering for a compose-seconds plan, or null when the
424
+ // plan is not one. A CADENCE second over a stepped minute uses the ordinal
425
+ // cadence form; a CLOCK-POINT second (list/range/single) over any restricted
426
+ // minute uses the genitive form anchored to the confined minute. Both bind the
427
+ // second beneath the minute instead of juxtaposing the two behind a comma.
428
+ // Folded into one helper so `renderComposeSeconds` carries a single branch.
429
+ function minuteConfinementRender(
430
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
431
+ schedule: Schedule, opts: Opts
432
+ ): string | null {
433
+ if (isSteppedMinuteSeconds(schedule, plan)) {
434
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
435
+ }
436
+
437
+ const minuteRest = plan.rest.kind === 'minuteFrequency' ||
438
+ plan.rest.kind === 'multipleMinutes' ||
439
+ plan.rest.kind === 'rangeOfMinutes';
440
+
441
+ if (minuteRest && secondsConfinesMinute(schedule)) {
442
+ return secondsBareLead(schedule) + ' ' +
443
+ confinedMinutePhrase(schedule, opts) + trailingQualifier(schedule, opts);
444
+ }
445
+
446
+ return null;
447
+ }
448
+
449
+ function secondsConfinesMinute(schedule: Schedule): boolean {
450
+ const {second, minute, hour} = schedule.shapes;
451
+
452
+ // A second LIST the core enumerated from a step (`*/15` → 0,15,30,45; `3/2` →
453
+ // 3,5,…) is really a stride CADENCE, spoken "cada N segundos" and confined by
454
+ // the cadence path, not a clock-point clause; exclude it here.
455
+ if (second === 'list') {
456
+ const values = singleValues(segmentsOf(schedule, 'second'));
457
+
458
+ if (values && arithmeticStep(values)) {
459
+ return false;
460
+ }
461
+ }
462
+
463
+ const clockPoint = second === 'single' || second === 'range' ||
464
+ second === 'list';
465
+
466
+ return clockPoint && minute !== 'wildcard' && hour === 'wildcard' &&
467
+ !(second === 'single' && minute === 'single');
468
+ }
469
+
470
+ // The seconds lead plus its connector for a generic compose-seconds fallback:
471
+ // empty when the rest already owns the second (a compact clock time folding a
472
+ // meaningful single second); a bare space when the rest is a locative "en …"
473
+ // minute LIST/SINGLE under a wildcard hour (the locative binds the two specs,
474
+ // so no comma); otherwise the comma that sets the seconds clause apart.
475
+ function composeConnector(
476
+ schedule: Schedule,
477
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
478
+ opts: Opts
479
+ ): string {
480
+ if (plan.rest.kind === 'compactClockTimes' && schedule.analyses.clockSecond) {
481
+ return '';
482
+ }
483
+
484
+ const locative = (plan.rest.kind === 'multipleMinutes' ||
485
+ plan.rest.kind === 'singleMinute') && schedule.shapes.hour === 'wildcard';
486
+
487
+ return secondsLeadClause(schedule, opts) + (locative ? ' ' : ', ');
488
+ }
489
+
348
490
  function renderComposeSeconds(
349
491
  schedule: Schedule,
350
492
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -388,12 +530,15 @@ function renderComposeSeconds(
388
530
  return dayFrame + ', ' + window + ', ' + cadence;
389
531
  }
390
532
 
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);
533
+ // A second confines the minute restriction (open hour), never the comma
534
+ // juxtaposition that reads as two independent cadences: a CADENCE second over
535
+ // a stepped minute uses the ordinal-cadence form ("cada segundo en cada sexto
536
+ // minuto …"); a CLOCK-POINT second uses the genitive form anchored to the
537
+ // confined minute ("en los segundos 5, 10 y 15 de cada sexto minuto …").
538
+ const confined = minuteConfinementRender(plan, schedule, opts);
539
+
540
+ if (confined !== null) {
541
+ return confined;
397
542
  }
398
543
 
399
544
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
@@ -408,11 +553,12 @@ function renderComposeSeconds(
408
553
 
409
554
  // A compact clock-time rest folds a meaningful SINGLE second into its own
410
555
  // leading clause, so the composer must not prepend a second lead that would
411
- // double it. A wildcard or stepped second is not folded there (no
412
- // clockSecond), so it still leads its own clause here.
413
- const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
414
- schedule.analyses.clockSecond;
415
- const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
556
+ // double it (empty connector). A cadence/stepped second under a minute
557
+ // LIST or SINGLE and a wildcard hour leads straight into the locative "en …"
558
+ // minute phrase with NO comma ("cada segundo en los minutos 0, 15 y 30 de
559
+ // cada hora") — the locative binds the two specs, matching the no-comma
560
+ // stepped-minute and list-tier confinements. Every other rest takes a comma.
561
+ const lead = composeConnector(schedule, plan, opts);
416
562
 
417
563
  return lead + render(schedule, plan.rest, opts);
418
564
  }