cronli5 0.8.3 → 0.8.6

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.
@@ -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
  }
@@ -346,6 +346,13 @@ function renderSecondsWithinMinute(
346
346
  units.second.gen + ' kohdalla' + trailingQualifier(schedule, opts);
347
347
  }
348
348
 
349
+ // A second LIST or RANGE under a single minute folds both into one shared
350
+ // "kohdalla" ("joka tunti 30 minuutin ja 5 ja 10 sekunnin kohdalla"), never
351
+ // the comma juxtaposition; a STEP second is a cadence and keeps its clause.
352
+ if (secondsConfinesMinute(schedule)) {
353
+ return secondsConfinement(schedule, opts);
354
+ }
355
+
349
356
  return secondsLeadClause(schedule, opts) + ', ' +
350
357
  atMarks(minuteField, units.minute, true) +
351
358
  trailingQualifier(schedule, opts);
@@ -480,6 +487,68 @@ function isSteppedMinuteSeconds(
480
487
  minuteStride(schedule) !== null;
481
488
  }
482
489
 
490
+ // Whether a clock-point second (list, range, or single) sits under a restricted
491
+ // minute and a wildcard hour — the shape that must CONFINE the minute rather
492
+ // than juxtapose it behind a comma (two independent schedules). A second LIST
493
+ // the core enumerated from a step (`3/2`) is really a stride cadence and stays
494
+ // out. The single-second + single-minute pair folds into one shared "kohdalla"
495
+ // already ("joka tunti 30 minuutin ja 15 sekunnin kohdalla") and is excluded.
496
+ function secondsConfinesMinute(schedule: Schedule): boolean {
497
+ const {second, minute, hour} = schedule.shapes;
498
+
499
+ if (second === 'list') {
500
+ const values = singleValues(segmentsOf(schedule, 'second'));
501
+
502
+ if (values && arithmeticStep(values)) {
503
+ return false;
504
+ }
505
+ }
506
+
507
+ const clockPoint = second === 'single' || second === 'range' ||
508
+ second === 'list';
509
+
510
+ return clockPoint && minute !== 'wildcard' && hour === 'wildcard' &&
511
+ !(second === 'single' && minute === 'single');
512
+ }
513
+
514
+ // Whether a compose-seconds plan over a minute-cadence/list/range rest carries
515
+ // a clock-point second that must confine the minute — the gate for the "的"/
516
+ // shared-"kohdalla" fusion in `renderComposeSeconds`, kept out of that function
517
+ // to hold its branch count down.
518
+ function composeConfinesMinute(
519
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>, schedule: Schedule
520
+ ): boolean {
521
+ const minuteRest = plan.rest.kind === 'minuteFrequency' ||
522
+ plan.rest.kind === 'multipleMinutes' ||
523
+ plan.rest.kind === 'rangeOfMinutes';
524
+
525
+ return minuteRest && secondsConfinesMinute(schedule);
526
+ }
527
+
528
+ // Confine a restricted minute under a clock-point second. A STEPPED minute
529
+ // keeps the essive ordinal cadence frame ("joka kuudentena minuuttina jokaisen
530
+ // tunnin minuutista 4 alkaen") and the seconds trail as their postposition,
531
+ // mirroring the open-minute "joka minuutti 5, 10 ja 15 sekunnin kohdalla". A
532
+ // list, range, or single minute folds BOTH fields into one shared "kohdalla"
533
+ // ("joka tunti 0, 15 ja 30 minuutin ja 5, 10 ja 15 sekunnin kohdalla"), the
534
+ // same fusion the single-second case uses — never the comma juxtaposition.
535
+ function secondsConfinement(
536
+ schedule: Schedule, opts: NormalizedOptions
537
+ ): string {
538
+ const stride = minuteStride(schedule);
539
+
540
+ if (stride && schedule.pattern.minute !== '*/2') {
541
+ return minuteStepConfinement(schedule, stride, opts);
542
+ }
543
+
544
+ const minuteDigits = joinList(segmentWords(segmentsOf(schedule, 'minute')));
545
+ const secondDigits = joinList(segmentWords(segmentsOf(schedule, 'second')));
546
+
547
+ return units.minute.mark + ' ' + minuteDigits + ' ' + units.minute.gen +
548
+ ' ja ' + secondDigits + ' ' + units.second.gen + ' kohdalla' +
549
+ trailingQualifier(schedule, opts);
550
+ }
551
+
483
552
  function renderComposeSeconds(
484
553
  schedule: Schedule,
485
554
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -505,6 +574,14 @@ function renderComposeSeconds(
505
574
  return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
506
575
  }
507
576
 
577
+ // A clock-point second (list/range/single) under a restricted minute confines
578
+ // that minute, never the comma juxtaposition that reads as two schedules: a
579
+ // stepped minute keeps its essive frame with the seconds postposition; a
580
+ // list, range, or single minute folds both into one shared "kohdalla".
581
+ if (composeConfinesMinute(plan, schedule)) {
582
+ return secondsConfinement(schedule, opts);
583
+ }
584
+
508
585
  // When the rest is a minute-step cadence, the step leads and the second
509
586
  // anchor follows after a comma (the comma marks the granularity boundary
510
587
  // between the two levels, not a flat list).
@@ -127,16 +127,6 @@ const weekdayNames = [
127
127
  const nthWeekdayMasculine =
128
128
  [null, 'premier', 'deuxième', 'troisième', 'quatrième', 'cinquième'];
129
129
 
130
- // French ordinals (gender-neutral "-ième") for a stepped-minute cadence under a
131
- // seconds lead ("à la sixième minute"). The interval-2 step keeps its own
132
- // idiom and never reaches here; a lookup miss falls back to the cardinal-with-
133
- // preposition form, which still confines (see `minuteStepConfinement`).
134
- const stepOrdinals: Record<number, string> = {
135
- 3: 'troisième', 4: 'quatrième', 5: 'cinquième', 6: 'sixième',
136
- 7: 'septième', 8: 'huitième', 9: 'neuvième', 10: 'dixième',
137
- 12: 'douzième', 15: 'quinzième', 20: 'vingtième', 30: 'trentième'
138
- };
139
-
140
130
  // Normalize raw user options.
141
131
  function normalizeOptions(options?: Cronli5Options): Opts {
142
132
  options = options || {};
@@ -227,6 +217,14 @@ function renderSecondsWithinMinute(
227
217
  trailingQualifier(schedule, opts);
228
218
  }
229
219
 
220
+ // A second LIST or RANGE under a single minute confines that minute in the
221
+ // genitive ("aux secondes 5 et 10 de la minute 30 de chaque heure"), never
222
+ // the comma juxtaposition; a STEP second is a cadence and keeps its own lead.
223
+ if (secondsConfinesMinute(schedule)) {
224
+ return secondsBareLead(schedule) + ' ' +
225
+ confinedMinutePhrase(schedule) + trailingQualifier(schedule, opts);
226
+ }
227
+
230
228
  return secondsLeadClause(schedule, opts) + ', à la minute ' + minuteField +
231
229
  ' de chaque heure' + trailingQualifier(schedule, opts);
232
230
  }
@@ -309,30 +307,22 @@ function minuteStride(
309
307
  return values && arithmeticStep(values);
310
308
  }
311
309
 
312
- // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
313
- // second cadence to the minute cadence as a CONFINEMENT ("chaque seconde à la
314
- // sixième minute à partir de la minute 4 de chaque heure"), never the comma
315
- // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
316
- // ("à la sixième minute") the cardinal "toutes les six minutes" is what fuels
317
- // the misread and the start/bound mirror the standalone minute cadence.
318
- function minuteStepConfinement(
310
+ // A stepped minute under a wildcard hour: the second clause leads, a COMMA,
311
+ // then the minute's own STANDALONE cardinal cadence ("chaque seconde, toutes
312
+ // les six minutes à partir de la minute 4 de chaque heure"; "aux secondes 5,
313
+ // 10 et 15, toutes les six minutes …"). The ordinal la sixième minute" read
314
+ // as a single minute (the 10th), not the every-sixth series; the standalone
315
+ // cardinal "toutes les six minutes" reads it correctly and handles every stride
316
+ // (offset, bounded, uneven) for free. The lead is the cadence clause for a
317
+ // wildcard/stepped second, the bare clock-point clause for a list/range/single.
318
+ function steppedMinuteConfinement(
319
319
  schedule: Schedule,
320
- stride: {start: number; interval: number; last: number},
320
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
321
+ lead: string,
321
322
  opts: Opts
322
323
  ): string {
323
- const ordinal = stepOrdinals[stride.interval];
324
- const head = ordinal ?
325
- 'à la ' + ordinal + ' minute' :
326
- 'à la minute toutes les ' + numero(stride.interval, opts);
327
-
328
- const tail = chooseStride({...stride, cycle: 60}, {
329
- bare: () => '',
330
- offset: () => ' à partir de la minute ' + stride.start,
331
- bounded: () => ' de la minute ' + stride.start + ' à ' + stride.last
332
- });
333
-
334
- return secondsLeadClause(schedule, opts) + ' ' + head + tail +
335
- ' de chaque heure' + trailingQualifier(schedule, opts);
324
+ return lead + ', ' + render(schedule, plan.rest, opts) +
325
+ trailingQualifier(schedule, opts);
336
326
  }
337
327
 
338
328
  // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
@@ -350,6 +340,108 @@ function isSteppedMinuteSeconds(
350
340
  minuteStride(schedule) !== null;
351
341
  }
352
342
 
343
+ // The leading seconds words for a clock-point second, WITHOUT the trailing "de
344
+ // chaque minute" anchor: a confined second attaches to the CONFINED minute ("de
345
+ // la sixième minute…"), so the generic minute anchor would be redundant.
346
+ function secondsBareLead(schedule: Schedule): string {
347
+ const secondField = schedule.pattern.second;
348
+ const shape = schedule.shapes.second;
349
+
350
+ if (shape === 'range') {
351
+ const bounds = secondField.split('-');
352
+
353
+ return 'chaque seconde de ' + bounds[0] + ' à ' + bounds[1];
354
+ }
355
+
356
+ if (shape === 'single') {
357
+ return 'à la seconde ' + secondField;
358
+ }
359
+
360
+ return 'aux secondes ' +
361
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
362
+ }
363
+
364
+ // The CONFINED-minute genitive phrase a clock-point second attaches to ("des
365
+ // minutes 0, 15 et 30 de chaque heure", "de la minute 30 de chaque heure", "de
366
+ // chaque minute de 0 à 30 de chaque heure"). A stepped minute is handled by the
367
+ // standalone-cadence confinement before this point; a list, range, or single
368
+ // names the minute(s) in the genitive — so the bare seconds lead never stacks a
369
+ // redundant "de chaque minute".
370
+ function confinedMinutePhrase(schedule: Schedule): string {
371
+ if (schedule.shapes.minute === 'range') {
372
+ return 'de ' + minuteRangeLead(schedule.pattern.minute) +
373
+ ' de chaque heure';
374
+ }
375
+
376
+ if (schedule.shapes.minute === 'list') {
377
+ return 'des minutes ' +
378
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) +
379
+ ' de chaque heure';
380
+ }
381
+
382
+ return 'de la minute ' + schedule.pattern.minute + ' de chaque heure';
383
+ }
384
+
385
+ // Whether a clock-point second (list, range, or single) sits under a restricted
386
+ // minute and a wildcard hour — the shape that must CONFINE the minute in the
387
+ // genitive rather than juxtapose it behind a comma (two independent schedules).
388
+ // A second LIST the core enumerated from a step (`3/2`) is really a stride
389
+ // cadence and stays out. The single-second + single-minute pair folds into one
390
+ // coherent clock point and is excluded.
391
+ function secondsConfinesMinute(schedule: Schedule): boolean {
392
+ const {second, minute, hour} = schedule.shapes;
393
+
394
+ if (second === 'list') {
395
+ const values = singleValues(segmentsOf(schedule, 'second'));
396
+
397
+ if (values && arithmeticStep(values)) {
398
+ return false;
399
+ }
400
+ }
401
+
402
+ const clockPoint = second === 'single' || second === 'range' ||
403
+ second === 'list';
404
+
405
+ return clockPoint && minute !== 'wildcard' && hour === 'wildcard' &&
406
+ !(second === 'single' && minute === 'single');
407
+ }
408
+
409
+ // The minute-confinement rendering for a compose-seconds plan, or null when the
410
+ // plan is not one. A STEPPED minute (cadence or clock-point second) leads with
411
+ // the second clause, a comma, then the minute's own standalone cardinal
412
+ // cadence; a CLOCK-POINT second over a LIST/RANGE/SINGLE minute uses the
413
+ // genitive form anchored to the confined minute. Both bind the second beneath
414
+ // the minute instead of juxtaposing the two behind a bare comma + "de chaque
415
+ // minute". Folded into one helper so `renderComposeSeconds` carries a single
416
+ // branch.
417
+ function minuteConfinementRender(
418
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
419
+ schedule: Schedule, opts: Opts
420
+ ): string | null {
421
+ if (isSteppedMinuteSeconds(schedule, plan)) {
422
+ return steppedMinuteConfinement(schedule, plan,
423
+ secondsLeadClause(schedule, opts), opts);
424
+ }
425
+
426
+ const minuteRest = plan.rest.kind === 'minuteFrequency' ||
427
+ plan.rest.kind === 'multipleMinutes' ||
428
+ plan.rest.kind === 'rangeOfMinutes';
429
+
430
+ if (minuteRest && secondsConfinesMinute(schedule)) {
431
+ // A clock-point second over a STEPPED minute reuses the standalone cardinal
432
+ // cadence the same way; only a list/range/single minute keeps the genitive.
433
+ if (minuteStride(schedule) && schedule.pattern.minute !== '*/2') {
434
+ return steppedMinuteConfinement(schedule, plan,
435
+ secondsBareLead(schedule), opts);
436
+ }
437
+
438
+ return secondsBareLead(schedule) + ' ' +
439
+ confinedMinutePhrase(schedule) + trailingQualifier(schedule, opts);
440
+ }
441
+
442
+ return null;
443
+ }
444
+
353
445
  function renderComposeSeconds(
354
446
  schedule: Schedule,
355
447
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -397,8 +489,15 @@ function renderComposeSeconds(
397
489
  // the second cadence to the ordinal minute cadence ("chaque seconde à la
398
490
  // sixième minute à partir de la minute 4 de chaque heure"), never the comma
399
491
  // juxtaposition that reads as two independent cadences.
400
- if (isSteppedMinuteSeconds(schedule, plan)) {
401
- return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
492
+ // A second confines the minute restriction (open hour), never the comma
493
+ // juxtaposition that reads as two independent cadences: a CADENCE second over
494
+ // a stepped minute uses the ordinal-cadence form ("chaque seconde à la
495
+ // sixième minute …"); a CLOCK-POINT second uses the genitive form anchored to
496
+ // the confined minute ("aux secondes 5, 10 et 15 de la sixième minute …").
497
+ const confined = minuteConfinementRender(plan, schedule, opts);
498
+
499
+ if (confined !== null) {
500
+ return confined;
402
501
  }
403
502
 
404
503
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two