cronli5 0.1.4 → 0.1.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.
@@ -9,8 +9,8 @@
9
9
  // lists render as per-hour windows).
10
10
 
11
11
  import {clockDigits, numeral} from '../../core/format.js';
12
- import {weekdayNumbers} from '../../core/specs.js';
13
- import {toFieldNumber} from '../../core/util.js';
12
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
13
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
14
14
  import type {Cronli5Options} from '../../types.js';
15
15
  import type {
16
16
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -27,6 +27,20 @@ type Renderer = (ir: IR, plan: PlanNode, opts: Opts) => string;
27
27
  // A `step` segment, narrowed from the discriminated `Segment` union.
28
28
  type StepSegment = Extract<Segment, {kind: 'step'}>;
29
29
 
30
+ // A step cadence to phrase: the `interval` repeats over a `cycle`-long field
31
+ // (60 for minute/second), running from `start` to `last`. `unit` is the
32
+ // singular noun and `anchor` the larger unit the values count against. When
33
+ // `anchor` is empty the caller supplies its own trailing scope, so the cadence
34
+ // drops the "de cada <anchor>" tail.
35
+ interface Stride {
36
+ interval: number;
37
+ start: number;
38
+ last: number;
39
+ cycle: number;
40
+ unit: string;
41
+ anchor: string;
42
+ }
43
+
30
44
  // One end of a clock-time range. The second is optional and may be absent
31
45
  // (top-of-hour windows) or a folded clock second.
32
46
  type ClockEnd = {hour: number; minute: number; second?: number | null};
@@ -206,11 +220,65 @@ function renderSecondsWithinMinute(
206
220
  ' de cada hora' + trailingQualifier(ir, opts);
207
221
  }
208
222
 
223
+ // A seconds list nested into one or more fixed clock times ("..., en los
224
+ // segundos 5 y 30 de las 09:00 y 17:00"). An offset/uneven second step the
225
+ // core enumerated to this list reads as a stride cadence; otherwise the fires
226
+ // are listed. The clock time follows with the genitive "de", so the stride
227
+ // drops its "de cada minuto" anchor.
228
+ function secondsListAtClock(
229
+ ir: IR,
230
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
231
+ opts: Opts
232
+ ): string {
233
+ const clockPhrases = rest.times.map(function clock(time) {
234
+ return atTime(timePhrase(time.hour, time.minute, null, opts));
235
+ });
236
+ const grouped = groupClockTimesByArticle(clockPhrases);
237
+ // Strip the leading "a " prefix from the grouped result so the caller can
238
+ // prepend "de " to produce the genitive form "de las 09:00 y 17:00".
239
+ const clockList = grouped.startsWith('a ') ? grouped.slice(2) : grouped;
240
+ const stride =
241
+ strideFromSegments(fieldSegments(ir, 'second'), 'segundo', '', opts);
242
+ const secondsPhrase = stride ?? 'en los segundos ' +
243
+ joinList(segmentWords(fieldSegments(ir, 'second')));
244
+ const dayFrame = trailingQualifier(ir, opts);
245
+
246
+ return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
247
+ secondsPhrase + ' de ' + clockList;
248
+ }
249
+
250
+ // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
251
+ // would cross-multiply an hour stride under a single pinned minute, or null
252
+ // when that does not apply (a non-clock rest, a multi-valued minute, or an
253
+ // hour that is not a stride).
254
+ function composeHourCadence(
255
+ ir: IR,
256
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
257
+ opts: Opts
258
+ ): string | null {
259
+ const clockRest = plan.rest.kind === 'clockTimes' ||
260
+ plan.rest.kind === 'compactClockTimes';
261
+
262
+ return clockRest && ir.shapes.minute === 'single' ?
263
+ hourCadence(ir, +ir.pattern.minute, opts) :
264
+ null;
265
+ }
266
+
209
267
  function renderComposeSeconds(
210
268
  ir: IR,
211
269
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
212
270
  opts: Opts
213
271
  ): string {
272
+ // An hour step (or arithmetic-progression hour list) under a single pinned
273
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
274
+ // then the hour cadence ("en el segundo 30 de cada hora, cada dos horas").
275
+ // The clock-time rest would otherwise cross-multiply the hours.
276
+ const hourCad = composeHourCadence(ir, plan, opts);
277
+
278
+ if (hourCad !== null) {
279
+ return hourCad;
280
+ }
281
+
214
282
  // A wildcard or stepped second with the minute pinned to a single value
215
283
  // across one or more specific hours: the seconds confine to the clock time.
216
284
  if (plan.rest.kind === 'clockTimes' &&
@@ -223,19 +291,7 @@ function renderComposeSeconds(
223
291
  // fixed so "de cada minuto" is misleading. Single seconds already fold into
224
292
  // the time in the clockTimes renderer; step seconds keep their own clause.
225
293
  if (plan.rest.kind === 'clockTimes' && ir.shapes.second === 'list') {
226
- const clockPhrases = plan.rest.times.map(function clock(time) {
227
- return atTime(timePhrase(time.hour, time.minute, null, opts));
228
- });
229
- const grouped = groupClockTimesByArticle(clockPhrases);
230
- // Strip the leading "a " prefix from the grouped result so the caller can
231
- // prepend "de " to produce the genitive form "de las 09:00 y 17:00".
232
- const clockList = grouped.startsWith('a ') ? grouped.slice(2) : grouped;
233
- const secondsPhrase = 'en los segundos ' +
234
- joinList(segmentWords(fieldSegments(ir, 'second')));
235
- const dayFrame = trailingQualifier(ir, opts);
236
-
237
- return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
238
- secondsPhrase + ' de ' + clockList;
294
+ return secondsListAtClock(ir, plan.rest, opts);
239
295
  }
240
296
 
241
297
  // Second-step + fixed minute + hour range + weekday: anchor the cadence to
@@ -252,9 +308,34 @@ function renderComposeSeconds(
252
308
  return dayFrame + ', ' + window + ', ' + cadence;
253
309
  }
254
310
 
311
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
312
+ // cadences that read as contradictory ("cada segundo, cada dos minutos").
313
+ // Bind them with the genitive "de" ("cada segundo de cada dos minutos"),
314
+ // mirroring English. Other strides, a restricted hour, and an hour cadence
315
+ // keep the juxtaposed form.
316
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
317
+ return secondsLeadClause(ir, opts) + ' de ' + render(ir, plan.rest, opts);
318
+ }
319
+
255
320
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
256
321
  }
257
322
 
323
+ // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
324
+ // cadences read as contradictory side by side, so they bind into one.
325
+ function isEveryOtherMinuteSeconds(
326
+ ir: IR,
327
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
328
+ ): boolean {
329
+ if (plan.rest.kind !== 'minuteFrequency' ||
330
+ ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
331
+ return false;
332
+ }
333
+
334
+ const minuteStep = stepSegment(ir.analyses.segments.minute);
335
+
336
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
337
+ }
338
+
258
339
  // A wildcard or stepped second under a single pinned minute and specific
259
340
  // hour(s). The clock-time rest folds the minute into the hour, and on the
260
341
  // 12-hour clock a pinned minute-0 drops the :00 entirely ("a las 9 de la
@@ -284,6 +365,15 @@ function pinnedMinuteSeconds(
284
365
 
285
366
  // The leading clause describing a second field relative to the minute.
286
367
  function secondsLeadClause(ir: IR, opts: Opts): string {
368
+ return secondsClause(ir, 'minuto', opts);
369
+ }
370
+
371
+ // The second clause counted against an arbitrary anchor. The anchor is
372
+ // "minuto" in the standalone seconds path; the hour-cadence path folds a
373
+ // pinned minute 0 into the hour and counts the second "de cada hora" instead
374
+ // ("en el segundo 30 de cada hora"), so the minute-0 confinement is stated,
375
+ // not dropped.
376
+ function secondsClause(ir: IR, anchor: string, opts: Opts): string {
287
377
  const secondField = ir.pattern.second;
288
378
  const shape = ir.shapes.second;
289
379
 
@@ -293,23 +383,24 @@ function secondsLeadClause(ir: IR, opts: Opts): string {
293
383
 
294
384
  if (shape === 'step') {
295
385
  return stepCycle60(stepSegment(ir.analyses.segments.second), 'segundo',
296
- 'minuto', opts);
386
+ anchor, opts);
297
387
  }
298
388
 
299
389
  if (shape === 'range') {
300
390
  const bounds = secondField.split('-');
301
391
 
302
392
  return 'cada segundo del ' + bounds[0] + ' al ' + bounds[1] +
303
- ' de cada minuto';
393
+ ' de cada ' + anchor;
304
394
  }
305
395
 
306
396
  if (shape === 'single') {
307
- return 'en el segundo ' + secondField + ' de cada minuto';
397
+ return 'en el segundo ' + secondField + ' de cada ' + anchor;
308
398
  }
309
399
 
310
- return 'en los segundos ' +
400
+ return strideFromSegments(fieldSegments(ir, 'second'), 'segundo', anchor,
401
+ opts) ?? 'en los segundos ' +
311
402
  joinList(segmentWords(fieldSegments(ir, 'second'))) +
312
- ' de cada minuto';
403
+ ' de cada ' + anchor;
313
404
  }
314
405
 
315
406
  // --- Minute renderers. ---
@@ -345,12 +436,15 @@ function renderMultipleMinutes(
345
436
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
346
437
  opts: Opts
347
438
  ): string {
348
- return minutesList(ir) + trailingQualifier(ir, opts);
439
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
349
440
  }
350
441
 
351
- // "en los minutos 5, 10 y 30 de cada hora".
352
- function minutesList(ir: IR): string {
353
- return 'en los minutos ' +
442
+ // "en los minutos 5, 10 y 30 de cada hora". An offset/uneven step the core
443
+ // enumerated to this list reads as a stride cadence when the fires form a
444
+ // long-enough progression.
445
+ function minutesList(ir: IR, opts: Opts): string {
446
+ return strideFromSegments(fieldSegments(ir, 'minute'), 'minuto', 'hora',
447
+ opts) ?? 'en los minutos ' +
354
448
  joinList(segmentWords(fieldSegments(ir, 'minute'))) + ' de cada hora';
355
449
  }
356
450
 
@@ -521,7 +615,7 @@ function renderMinutesAcrossHours(
521
615
 
522
616
  const lead = plan.form === 'range' ?
523
617
  minuteRangeLead(ir.pattern.minute) :
524
- minutesList(ir);
618
+ minutesList(ir, opts);
525
619
 
526
620
  return lead + ', ' + atHourTimes(ir, plan.times, opts) +
527
621
  trailingQualifier(ir, opts);
@@ -541,8 +635,14 @@ function renderMinuteSpanAcrossHourStep(
541
635
  trailingQualifier(ir, opts);
542
636
  }
543
637
 
544
- return minuteRangeLead(ir.pattern.minute) + ', ' + stepHours(segment, opts) +
545
- trailingQualifier(ir, opts);
638
+ // A minute list keeps the same cadence clause as the range; only its lead
639
+ // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
640
+ // 30").
641
+ const lead = plan.form === 'list' ?
642
+ minutesList(ir, opts) :
643
+ minuteRangeLead(ir.pattern.minute);
644
+
645
+ return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
546
646
  }
547
647
 
548
648
  // --- Hour renderers. ---
@@ -579,7 +679,7 @@ function renderHourRange(
579
679
 
580
680
  const lead = ir.shapes.minute === 'single' ?
581
681
  'en el minuto ' + ir.pattern.minute + ' de cada hora' :
582
- minutesList(ir);
682
+ minutesList(ir, opts);
583
683
 
584
684
  return lead + ', ' + window + trailingQualifier(ir, opts);
585
685
  }
@@ -717,6 +817,16 @@ function renderClockTimes(
717
817
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
718
818
  opts: Opts
719
819
  ): string {
820
+ // An hour step (or arithmetic-progression hour list) under a single pinned
821
+ // minute reads as a cadence rather than a cross-product of clock times.
822
+ if (ir.shapes.minute === 'single') {
823
+ const cadence = hourCadence(ir, +ir.pattern.minute, opts);
824
+
825
+ if (cadence !== null) {
826
+ return cadence;
827
+ }
828
+ }
829
+
720
830
  const phrases = plan.times.map(function clock(time) {
721
831
  return atTime(timePhrase(time.hour, time.minute, time.second, opts));
722
832
  });
@@ -1052,6 +1162,15 @@ function renderCompactClockTimes(
1052
1162
  opts: Opts
1053
1163
  ): string {
1054
1164
  if (plan.fold) {
1165
+ // An hour step (or arithmetic-progression hour list) under the single
1166
+ // pinned minute reads as a cadence, not a wall of clock times. (Returns
1167
+ // null for an irregular list or a range, which keep folding below.)
1168
+ const cadence = hourCadence(ir, plan.minute, opts);
1169
+
1170
+ if (cadence !== null) {
1171
+ return cadence;
1172
+ }
1173
+
1055
1174
  const ranged = hourSegments(ir).some(function range(segment) {
1056
1175
  return segment.kind === 'range';
1057
1176
  });
@@ -1068,7 +1187,7 @@ function renderCompactClockTimes(
1068
1187
  hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1069
1188
  }
1070
1189
 
1071
- const phrase = minutesList(ir) + ', ' +
1190
+ const phrase = minutesList(ir, opts) + ', ' +
1072
1191
  hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1073
1192
 
1074
1193
  return ir.analyses.clockSecond ?
@@ -1100,8 +1219,42 @@ const renderers = {
1100
1219
 
1101
1220
  // --- Step phrases. ---
1102
1221
 
1222
+ // Speak a step cadence over a `cycle`-long field (60 for minute/second). A
1223
+ // clean stride from the top of the cycle is the bare cadence ("cada quince
1224
+ // minutos"); a uniform offset (start within the first interval, the interval
1225
+ // still dividing the cycle) names only its start, since it wraps cleanly with
1226
+ // no distinct endpoint ("cada seis minutos a partir del minuto 5 de cada
1227
+ // hora"); a non-uniform stride (start >= interval, or an interval that does
1228
+ // not divide the cycle) pins both endpoints so the bounded, non-wrapping set
1229
+ // reads unambiguously ("cada dos minutos del minuto 3 al 59 de cada hora").
1230
+ // This is the one phrasing for every step the renderer speaks, whether the
1231
+ // core kept it a step shape (a clean cadence) or enumerated it to a fire list
1232
+ // (an offset/uneven set the list path recognizes as a progression).
1233
+ function renderStride(stride: Stride, opts: Opts): string {
1234
+ const {interval, start, last, cycle, unit, anchor} = stride;
1235
+ const cadence = 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1236
+ const tiles = cycle % interval === 0;
1237
+
1238
+ if (start === 0 && tiles) {
1239
+ return cadence;
1240
+ }
1241
+
1242
+ // A context that supplies its own trailing scope passes an empty anchor, so
1243
+ // the cadence keeps its endpoints but drops the "de cada <anchor>" tail.
1244
+ const tail = anchor ? ' de cada ' + anchor : '';
1245
+
1246
+ if (start < interval && tiles) {
1247
+ return cadence + ' a partir del ' + unit + ' ' + start + tail;
1248
+ }
1249
+
1250
+ return cadence + ' del ' + unit + ' ' + start + ' al ' + last + tail;
1251
+ }
1252
+
1103
1253
  // "cada 15 minutos", "en los minutos 5, 20 y 35 de cada hora", or
1104
- // "cada 15 minutos a partir del minuto 5 de cada hora".
1254
+ // "cada 15 minutos a partir del minuto 5 de cada hora". A step shape only
1255
+ // reaches here as a clean cadence (the interval divides 60), so the stride
1256
+ // collapses to the bare or uniform-offset form; an offset/uneven set arrives
1257
+ // as a fire list and is recognized by the list path instead.
1105
1258
  function stepCycle60(
1106
1259
  segment: StepSegment,
1107
1260
  unit: string,
@@ -1114,21 +1267,57 @@ function stepCycle60(
1114
1267
  }
1115
1268
 
1116
1269
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
1117
- const interval = segment.interval;
1118
1270
 
1119
- if (start !== 0) {
1120
- if (segment.fires.length <= 3) {
1121
- return 'en los ' + unit + 's ' + joinList(wordList(segment.fires)) +
1122
- ' de cada ' + anchor;
1271
+ // A short offset cadence still lists its fires; the stride phrasing names
1272
+ // the interval and offset only once there are enough fires to beat the list.
1273
+ if (start !== 0 && segment.fires.length <= 3) {
1274
+ return 'en los ' + unit + 's ' + joinList(wordList(segment.fires)) +
1275
+ ' de cada ' + anchor;
1276
+ }
1277
+
1278
+ return renderStride({
1279
+ interval: segment.interval,
1280
+ start,
1281
+ last: segment.fires[segment.fires.length - 1],
1282
+ cycle: 60,
1283
+ unit,
1284
+ anchor
1285
+ }, opts);
1286
+ }
1287
+
1288
+ // Speak a minute/second field's enumerated fires as a step cadence when they
1289
+ // form an arithmetic progression long enough to beat the list (the core
1290
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1291
+ // the renderer recognizes the progression). Returns null for a non-progression
1292
+ // or a too-short list, leaving the caller to enumerate.
1293
+ function strideFromSegments(
1294
+ segments: Segment[],
1295
+ unit: string,
1296
+ anchor: string,
1297
+ opts: Opts
1298
+ ): string | null {
1299
+ const values = singleValues(segments);
1300
+ const step = values && arithmeticStep(values);
1301
+
1302
+ return step ?
1303
+ renderStride({...step, cycle: 60, unit, anchor}, opts) :
1304
+ null;
1305
+ }
1306
+
1307
+ // The sorted numeric values a field's segments cover, or null if any segment
1308
+ // is not a discrete single (a range or sub-step is not a plain fire list).
1309
+ function singleValues(segments: Segment[]): number[] | null {
1310
+ const values: number[] = [];
1311
+
1312
+ for (const segment of segments) {
1313
+ if (segment.kind !== 'single') {
1314
+ return null;
1123
1315
  }
1124
1316
 
1125
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's a partir del ' +
1126
- unit + ' ' + start + ' de cada ' + anchor;
1317
+ values.push(+segment.value);
1127
1318
  }
1128
1319
 
1129
- // A clean stride from the top of the cycle is the bare cadence. (An uneven
1130
- // stride is rewritten to its fires upstream and never reaches here.)
1131
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1320
+ return values;
1132
1321
  }
1133
1322
 
1134
1323
  // "cada seis horas", "a las 9:00, a las 11:00 y a la 1:00", or "cada
@@ -1155,6 +1344,150 @@ function stepHours(segment: StepSegment, opts: Opts): string {
1155
1344
  timePhrase(start, 0, null, opts);
1156
1345
  }
1157
1346
 
1347
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
1348
+
1349
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
1350
+ // from midnight is the bare cadence ("cada dos horas"); a clean offset names
1351
+ // only its start ("cada seis horas a partir de las 02:00"); a bounded or
1352
+ // non-tiling stride pins both clock-time endpoints ("cada dos horas de las
1353
+ // 09:00 a las 17:00") so the bounded set reads unambiguously. Used wherever an
1354
+ // hour step (or arithmetic-progression hour list) would otherwise be
1355
+ // cross-multiplied into a wall of clock times.
1356
+ function hourStrideCadence(
1357
+ stride: {start: number; interval: number; last: number},
1358
+ opts: Opts
1359
+ ): string {
1360
+ const {start, interval, last} = stride;
1361
+ const cadence = 'cada ' + numero(interval, opts) + ' horas';
1362
+ const tiles = 24 % interval === 0;
1363
+
1364
+ if (start === 0 && tiles) {
1365
+ return cadence;
1366
+ }
1367
+
1368
+ if (start < interval && tiles) {
1369
+ return cadence + ' a partir de ' + timePhrase(start, 0, null, opts);
1370
+ }
1371
+
1372
+ return cadence + ' de ' + timePhrase(start, 0, null, opts) + ' a ' +
1373
+ timePhrase(last, 0, null, opts);
1374
+ }
1375
+
1376
+ // The hour field's stride, or null when the hour is not a cadence: a step
1377
+ // segment yields its {start, interval, last} directly; an all-single hour
1378
+ // list yields one only when its values form a long-enough arithmetic
1379
+ // progression (so an irregular list like 9,17 keeps enumerating). The IR is
1380
+ // unchanged — the renderer recognizes the stride and speaks it as a cadence
1381
+ // instead of the clock-time cross-product.
1382
+ function hourStride(
1383
+ ir: IR
1384
+ ): {start: number; interval: number; last: number} | null {
1385
+ const segments = fieldSegments(ir, 'hour');
1386
+
1387
+ if (segments.length === 1 && segments[0].kind === 'step') {
1388
+ const segment = segments[0];
1389
+ const start = segment.startToken === '*' ?
1390
+ 0 :
1391
+ +segment.startToken.split('-')[0];
1392
+
1393
+ return {interval: segment.interval, last: segment.fires[
1394
+ segment.fires.length - 1], start};
1395
+ }
1396
+
1397
+ const values = singleValues(segments);
1398
+ const step = values && arithmeticStep(values);
1399
+
1400
+ return step || null;
1401
+ }
1402
+
1403
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1404
+ // fills the minute (a "durante un minuto" frame at minute 0); a single 0 is
1405
+ // just the top of the minute (no clause); anything else needs its own clause.
1406
+ function subMinuteSecond(ir: IR): boolean {
1407
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
1408
+ }
1409
+
1410
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1411
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
1412
+ // list, or range second is counted "de cada hora" (the minute-0 is the top of
1413
+ // the hour), and a wildcard or sub-minute step second takes a "durante un
1414
+ // minuto" frame (the whole minute-0 window). A non-zero minute is a real clock
1415
+ // minute: the second leads with its own clause (if any), then the minute reads
1416
+ // "en el minuto M".
1417
+ function hourCadenceLead(ir: IR, minute: number, opts: Opts): string {
1418
+ if (minute === 0) {
1419
+ if (subMinuteSecond(ir)) {
1420
+ return secondsClause(ir, 'minuto', opts) + ' durante un minuto';
1421
+ }
1422
+
1423
+ return secondsClause(ir, 'hora', opts);
1424
+ }
1425
+
1426
+ const minutePhrase = 'en el minuto ' + minute;
1427
+
1428
+ // A single 0 second is just the top of the minute, so the minute leads
1429
+ // alone; any other second prefixes its own clause.
1430
+ if (ir.pattern.second === '0') {
1431
+ return minutePhrase;
1432
+ }
1433
+
1434
+ return secondsClause(ir, 'minuto', opts) + ', ' + minutePhrase;
1435
+ }
1436
+
1437
+ // Render an hour step (or arithmetic-progression hour list) under a single
1438
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1439
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1440
+ // Returns null when the hour is not a stride (an irregular list, a single
1441
+ // hour, or a range), or when the cross-product is short enough that
1442
+ // enumeration is no longer than the cadence: a meaningful second makes every
1443
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
1444
+ // the stride must exceed the clock-time cap, the same point at which the core
1445
+ // itself stops enumerating. Renderer-only; the IR is unchanged.
1446
+ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1447
+ const stride = hourStride(ir);
1448
+
1449
+ if (!stride) {
1450
+ return null;
1451
+ }
1452
+
1453
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1454
+
1455
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1456
+ return null;
1457
+ }
1458
+
1459
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1460
+ // stride is a confinement, not a juxtaposed cadence: it reads "durante un
1461
+ // minuto, durante las horas pares", reusing the hour-step confinement idiom
1462
+ // so the minute-0 window is never heard as the bare hour cadence.
1463
+ const confinement = minute === 0 && subMinuteSecond(ir) &&
1464
+ cleanStrideSegment(ir);
1465
+
1466
+ if (confinement) {
1467
+ return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1468
+ stepHourSpan(confinement, opts) + trailingQualifier(ir, opts);
1469
+ }
1470
+
1471
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1472
+ hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1473
+ }
1474
+
1475
+ // The hour step segment when the hour is a clean stride es renders as a
1476
+ // confinement phrase ("durante las horas pares"); null otherwise (an offset or
1477
+ // bounded step, an uneven stride, or an arithmetic-progression list, which
1478
+ // keep the bounded cadence form).
1479
+ function cleanStrideSegment(ir: IR): StepSegment | null {
1480
+ const segments = fieldSegments(ir, 'hour');
1481
+ const segment = segments.length === 1 && segments[0];
1482
+
1483
+ if (!segment || segment.kind !== 'step' ||
1484
+ segment.startToken.indexOf('-') !== -1) {
1485
+ return null;
1486
+ }
1487
+
1488
+ return segment;
1489
+ }
1490
+
1158
1491
  // --- Hour-time phrasing. ---
1159
1492
 
1160
1493
  // "a las 9:00" / "a la 1:00" / "al mediodía" for each fire hour.