cronli5 0.1.5 → 0.1.7

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.
@@ -10,7 +10,9 @@
10
10
 
11
11
  import {clockDigits, numeral} from '../../core/format.js';
12
12
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
13
- import {arithmeticStep, toFieldNumber} from '../../core/util.js';
13
+ import {
14
+ arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
15
+ } from '../../core/util.js';
14
16
  import type {Cronli5Options} from '../../types.js';
15
17
  import type {
16
18
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -259,9 +261,24 @@ function composeHourCadence(
259
261
  const clockRest = plan.rest.kind === 'clockTimes' ||
260
262
  plan.rest.kind === 'compactClockTimes';
261
263
 
262
- return clockRest && ir.shapes.minute === 'single' ?
263
- hourCadence(ir, +ir.pattern.minute, opts) :
264
- null;
264
+ if (!clockRest || ir.shapes.minute !== 'single') {
265
+ return null;
266
+ }
267
+
268
+ const minute = +ir.pattern.minute;
269
+
270
+ return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
271
+ }
272
+
273
+ // A wildcard or stepped second with a fixed minute across one or more specific
274
+ // hours: the seconds confine to the clock time(s), each minute named.
275
+ function isPinnedMinuteSeconds(
276
+ ir: IR,
277
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
278
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
279
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
280
+ return plan.rest.kind === 'clockTimes' &&
281
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step');
265
282
  }
266
283
 
267
284
  function renderComposeSeconds(
@@ -281,8 +298,7 @@ function renderComposeSeconds(
281
298
 
282
299
  // A wildcard or stepped second with the minute pinned to a single value
283
300
  // across one or more specific hours: the seconds confine to the clock time.
284
- if (plan.rest.kind === 'clockTimes' &&
285
- (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
301
+ if (isPinnedMinuteSeconds(ir, plan)) {
286
302
  return pinnedMinuteSeconds(ir, plan.rest, opts);
287
303
  }
288
304
 
@@ -299,7 +315,7 @@ function renderComposeSeconds(
299
315
  if (plan.rest.kind === 'hourRange' && ir.shapes.second === 'step' &&
300
316
  ir.pattern.weekday !== '*') {
301
317
  const restNode = plan.rest;
302
- const window = hourWindow(restNode, opts);
318
+ const window = hourWindow(boundedWindow(restNode), opts);
303
319
  const dayFrame = weekdayQualifier(ir) + monthScope(ir);
304
320
  const cadence = 'cada ' +
305
321
  numero(stepSegment(ir.analyses.segments.second).interval, opts) +
@@ -317,7 +333,15 @@ function renderComposeSeconds(
317
333
  return secondsLeadClause(ir, opts) + ' de ' + render(ir, plan.rest, opts);
318
334
  }
319
335
 
320
- return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
336
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
337
+ // leading clause, so the composer must not prepend a second lead that would
338
+ // double it. A wildcard or stepped second is not folded there (no
339
+ // clockSecond), so it still leads its own clause here.
340
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
341
+ ir.analyses.clockSecond;
342
+ const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
343
+
344
+ return lead + render(ir, plan.rest, opts);
321
345
  }
322
346
 
323
347
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
@@ -354,7 +378,12 @@ function pinnedMinuteSeconds(
354
378
  const dayTrail = leadingQualifier(ir, opts).trimEnd();
355
379
  const trail = dayTrail ? ', ' + dayTrail : '';
356
380
 
357
- if (+rest.times[0].minute === 0) {
381
+ // The "durante un minuto a las 9" duration form drops the clock minute, so it
382
+ // is correct only when the minute is a SINGLE 0 — every clock time at :00. A
383
+ // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
384
+ // minute, never collapse to the bare hour (which once repeated it, "a las 9 y
385
+ // 9"), so it takes the explicit clock list.
386
+ if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
358
387
  return secondsLeadClause(ir, opts) + ' durante un minuto ' +
359
388
  durationHourList(rest.times, opts) + trail;
360
389
  }
@@ -555,11 +584,20 @@ function renderMinuteFrequency(
555
584
  'hora', opts);
556
585
 
557
586
  if (plan.hours.kind === 'during') {
558
- // An offset step (e.g. 1/2) arrives here; a single step reads as a
559
- // confinement, not the verbose window list.
560
- phrase += singleHourStep(ir.analyses.segments.hour) ?
561
- ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) :
562
- ' ' + hourSpanFromTimes(ir, plan.hours.times, opts);
587
+ // A uneven hour stride confines the minute cadence to its own bounded hour
588
+ // cadence ("cada 15 minutos, cada cinco horas de las 00:00 a las 20:00").
589
+ const cadence = unevenHourCadence(ir, opts);
590
+
591
+ if (cadence) {
592
+ phrase += ', ' + cadence;
593
+ }
594
+ else {
595
+ // An offset step (e.g. 1/2) arrives here; a single step reads as a
596
+ // confinement, not the verbose window list.
597
+ phrase += singleHourStep(ir.analyses.segments.hour) ?
598
+ ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) :
599
+ ' ' + hourSpanFromTimes(ir, plan.hours.times, opts);
600
+ }
563
601
  }
564
602
  else if (plan.hours.kind === 'window') {
565
603
  phrase += ' ' + hourWindow(plan.hours, opts);
@@ -602,7 +640,15 @@ function renderMinutesAcrossHours(
602
640
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
603
641
  opts: Opts
604
642
  ): string {
643
+ // A uneven hour stride reads as a cadence, not a wall of hour columns: the
644
+ // minute lead, then "cada N horas de las X a las Y".
645
+ const cadence = unevenHourCadence(ir, opts);
646
+
605
647
  if (plan.form === 'wildcard') {
648
+ if (cadence !== null) {
649
+ return 'cada minuto, ' + cadence + trailingQualifier(ir, opts);
650
+ }
651
+
606
652
  if (singleHourStep(ir.analyses.segments.hour)) {
607
653
  return 'cada minuto, ' +
608
654
  stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) +
@@ -617,6 +663,10 @@ function renderMinutesAcrossHours(
617
663
  minuteRangeLead(ir.pattern.minute) :
618
664
  minutesList(ir, opts);
619
665
 
666
+ if (cadence !== null) {
667
+ return lead + ', ' + cadence + trailingQualifier(ir, opts);
668
+ }
669
+
620
670
  return lead + ', ' + atHourTimes(ir, plan.times, opts) +
621
671
  trailingQualifier(ir, opts);
622
672
  }
@@ -627,9 +677,12 @@ function renderMinuteSpanAcrossHourStep(
627
677
  opts: Opts
628
678
  ): string {
629
679
  const segment = stepSegment(ir.analyses.segments.hour);
680
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
681
+ // offset-clean step keeps its confinement / per-step phrasing.
682
+ const cadence = unevenHourCadence(ir, opts);
630
683
 
631
- // A wildcard minute (a cadence) is reached only for a clean stride and is
632
- // confined; a plain range is a per-hour window keyed to the step.
684
+ // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
685
+ // or uneven step routes through minutesAcrossHours instead) and is confined.
633
686
  if (plan.form === 'wildcard') {
634
687
  return 'cada minuto, ' + stepHourSpan(segment, opts) +
635
688
  trailingQualifier(ir, opts);
@@ -642,7 +695,8 @@ function renderMinuteSpanAcrossHourStep(
642
695
  minutesList(ir, opts) :
643
696
  minuteRangeLead(ir.pattern.minute);
644
697
 
645
- return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
698
+ return lead + ', ' +
699
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
646
700
  }
647
701
 
648
702
  // --- Hour renderers. ---
@@ -689,17 +743,31 @@ function renderHourStep(
689
743
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
690
744
  opts: Opts
691
745
  ): string {
746
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence ("cada
747
+ // dos horas de las 09:00 a las 17:00"); an offset-clean step keeps its bare
748
+ // or "a partir de" cadence.
749
+ const cadence = unevenHourCadence(ir, opts);
750
+
751
+ if (cadence !== null) {
752
+ return cadence + trailingQualifier(ir, opts);
753
+ }
754
+
692
755
  return stepHours(stepSegment(ir.analyses.segments.hour), opts) +
693
756
  trailingQualifier(ir, opts);
694
757
  }
695
758
 
696
- // The hour-range plan as a window whose closing minute honors `boundMinute`:
697
- // a bare close (`null`) lands on the top of the final hour (minute 0),
698
- // matching the minute-0 baseline, with the minutes stated separately.
759
+ // The hour-range plan as a window. The close lands on the top of the final
760
+ // hour (minute 0) unless the minute genuinely runs to the end of that hour
761
+ // i.e. a wildcard minute, which fills every minute and states no separate
762
+ // clause. A pinned/listed/ranged minute is named in its own lead clause, so
763
+ // folding it into the close too would read as a span ("a las 17:05") that
764
+ // contradicts the minute clause; the window stays bare ("a las 17:00").
699
765
  function boundedWindow(
700
766
  plan: Extract<PlanNode, {kind: 'hourRange'}>
701
767
  ): {from: number; to: number; last: number} {
702
- return {from: plan.from, last: plan.boundMinute ?? 0, to: plan.to};
768
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
769
+
770
+ return {from: plan.from, last, to: plan.to};
703
771
  }
704
772
 
705
773
  // "de las 9:00 a las 17:45": a window from the top of the first hour to
@@ -778,7 +846,9 @@ function dowArm(ir: IR): string {
778
846
  return quartz;
779
847
  }
780
848
 
781
- const segments = flattenSteps(fieldSegments(ir, 'weekday'));
849
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
850
+ // form. The IR stays canonical (Sunday=0). The helper flattens steps.
851
+ const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
782
852
  const allSingles = segments.every(function single(segment) {
783
853
  return segment.kind === 'single';
784
854
  });
@@ -817,10 +887,13 @@ function renderClockTimes(
817
887
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
818
888
  opts: Opts
819
889
  ): 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.
890
+ // An hour step or range (or arithmetic-progression hour list) under a single
891
+ // pinned minute reads as a cadence or window rather than a cross-product of
892
+ // clock times.
822
893
  if (ir.shapes.minute === 'single') {
823
- const cadence = hourCadence(ir, +ir.pattern.minute, opts);
894
+ const minute = +ir.pattern.minute;
895
+ const cadence = hourCadence(ir, minute, opts) ??
896
+ hourRangeCadence(ir, minute, opts);
824
897
 
825
898
  if (cadence !== null) {
826
899
  return cadence;
@@ -1162,10 +1235,11 @@ function renderCompactClockTimes(
1162
1235
  opts: Opts
1163
1236
  ): string {
1164
1237
  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);
1238
+ // An hour step or range (or arithmetic-progression hour list) under the
1239
+ // single pinned minute reads as a cadence or window, not a wall of clock
1240
+ // times. (Returns null for an irregular list, which keeps folding below.)
1241
+ const cadence = hourCadence(ir, plan.minute, opts) ??
1242
+ hourRangeCadence(ir, plan.minute, opts);
1169
1243
 
1170
1244
  if (cadence !== null) {
1171
1245
  return cadence;
@@ -1187,8 +1261,13 @@ function renderCompactClockTimes(
1187
1261
  hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1188
1262
  }
1189
1263
 
1190
- const phrase = minutesList(ir, opts) + ', ' +
1191
- hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1264
+ // A uneven hour stride reads as a cadence after the minute lead, not a wall
1265
+ // of clock-time columns.
1266
+ const cadence = unevenHourCadence(ir, opts);
1267
+ const phrase = cadence ?
1268
+ minutesList(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
1269
+ minutesList(ir, opts) + ', ' +
1270
+ hourContextTimes(ir, opts) + trailingQualifier(ir, opts);
1192
1271
 
1193
1272
  return ir.analyses.clockSecond ?
1194
1273
  secondsLeadClause(ir, opts) + ', ' + phrase :
@@ -1373,12 +1452,75 @@ function hourStrideCadence(
1373
1452
  timePhrase(last, 0, null, opts);
1374
1453
  }
1375
1454
 
1455
+ // Whether an hour stride wraps the day cleanly from within its first interval
1456
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1457
+ // stride has no distinct endpoint and keeps its bare or "a partir de" cadence.
1458
+ // Every other stride — a uneven interval, or one starting at or past its
1459
+ // interval (a bounded `a-b/n`) — is a bounded set the cadence pins the ends of.
1460
+ function offsetCleanStride(
1461
+ stride: {start: number; interval: number}
1462
+ ): boolean {
1463
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1464
+ }
1465
+
1466
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1467
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1468
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1469
+ // enumerating list paths; there the bounded hour reads better as its cadence
1470
+ // ("…, cada cinco horas de las 00:00 a las 20:00") than as a wall of clock
1471
+ // times. An offset-clean stride keeps its existing confinement form, so only
1472
+ // the endpoint-bearing case routes here.
1473
+ function unevenHourCadence(ir: IR, opts: Opts): string | null {
1474
+ const stride = hourStride(ir);
1475
+
1476
+ if (!stride || offsetCleanStride(stride)) {
1477
+ return null;
1478
+ }
1479
+
1480
+ return hourStrideCadence(stride, opts);
1481
+ }
1482
+
1483
+ // An hour list's arithmetic progression, or null when its values are not a
1484
+ // step the renderer should speak as a cadence. The core rewrites a uneven hour
1485
+ // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
1486
+ // literal fire list, indistinguishable in the IR from a hand-written list; the
1487
+ // renderer recovers the cadence from the values. A progression starting at zero
1488
+ // is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is
1489
+ // only a step when it is too long to be a deliberate clock-time list (e.g. 9,17
1490
+ // is two named times, not a cadence). Interval one is a plain range, never a
1491
+ // step.
1492
+ function hourListStride(
1493
+ values: number[]
1494
+ ): {start: number; interval: number; last: number} | null {
1495
+ if (values.length < 2) {
1496
+ return null;
1497
+ }
1498
+
1499
+ const interval = values[1] - values[0];
1500
+
1501
+ if (interval < 2) {
1502
+ return null;
1503
+ }
1504
+
1505
+ for (let i = 2; i < values.length; i += 1) {
1506
+ if (values[i] - values[i - 1] !== interval) {
1507
+ return null;
1508
+ }
1509
+ }
1510
+
1511
+ if (values[0] !== 0 && values.length < 5) {
1512
+ return null;
1513
+ }
1514
+
1515
+ return {interval, last: values[values.length - 1], start: values[0]};
1516
+ }
1517
+
1376
1518
  // The hour field's stride, or null when the hour is not a cadence: a step
1377
1519
  // 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.
1520
+ // list yields one only when its values form a step progression (so an irregular
1521
+ // list like 9,17 keeps enumerating). The IR is unchanged — the renderer
1522
+ // recognizes the stride and speaks it as a cadence instead of the clock-time
1523
+ // cross-product.
1382
1524
  function hourStride(
1383
1525
  ir: IR
1384
1526
  ): {start: number; interval: number; last: number} | null {
@@ -1386,6 +1528,13 @@ function hourStride(
1386
1528
 
1387
1529
  if (segments.length === 1 && segments[0].kind === 'step') {
1388
1530
  const segment = segments[0];
1531
+
1532
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1533
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1534
+ if (segment.fires.length < 2) {
1535
+ return null;
1536
+ }
1537
+
1389
1538
  const start = segment.startToken === '*' ?
1390
1539
  0 :
1391
1540
  +segment.startToken.split('-')[0];
@@ -1395,9 +1544,8 @@ function hourStride(
1395
1544
  }
1396
1545
 
1397
1546
  const values = singleValues(segments);
1398
- const step = values && arithmeticStep(values);
1399
1547
 
1400
- return step || null;
1548
+ return values && hourListStride(values);
1401
1549
  }
1402
1550
 
1403
1551
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -1452,7 +1600,13 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1452
1600
 
1453
1601
  const fires = (stride.last - stride.start) / stride.interval + 1;
1454
1602
 
1455
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1603
+ // A short stride that spells out as few clock times stays an enumeration only
1604
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1605
+ // or "a partir de" form is no shorter than the list. A bounded or uneven
1606
+ // stride has no clean wrap, so its endpoint-pinning cadence ("cada cinco
1607
+ // horas de las 00:00 a las 20:00") reads better however short.
1608
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1609
+ offsetCleanStride(stride)) {
1456
1610
  return null;
1457
1611
  }
1458
1612
 
@@ -1468,6 +1622,13 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1468
1622
  stepHourSpan(confinement, opts) + trailingQualifier(ir, opts);
1469
1623
  }
1470
1624
 
1625
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1626
+ // lead clause to fold in, so the bounded cadence stands on its own ("cada
1627
+ // cinco horas de las 00:00 a las 20:00").
1628
+ if (minute === 0 && ir.pattern.second === '0') {
1629
+ return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1630
+ }
1631
+
1471
1632
  return hourCadenceLead(ir, minute, opts) + ', ' +
1472
1633
  hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1473
1634
  }
@@ -1488,8 +1649,112 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1488
1649
  return segment;
1489
1650
  }
1490
1651
 
1652
+ // Whether the hour field is a range — or a list whose segments include a
1653
+ // range — and so forms a window rather than a cross-product of clock times.
1654
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1655
+ // a step is handled by hourStride/hourCadence.
1656
+ function hasHourWindow(ir: IR): boolean {
1657
+ return hourSegments(ir).some(function range(segment) {
1658
+ return segment.kind === 'range';
1659
+ });
1660
+ }
1661
+
1662
+ // Render an hour range (or a list whose segments include a range) under
1663
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1664
+ // then "de las 09:00 a las 17:00" (and any non-contiguous hour joined with
1665
+ // "y también") — instead of cross-multiplying the hours into a wall of clock
1666
+ // times. The hour-RANGE analog of hourCadence. Returns null when the hour has
1667
+ // no range, when the minute is non-zero (a real clock minute the existing
1668
+ // window form already speaks), or when a plain :00 set carries no clause.
1669
+ // Renderer-only; the IR is unchanged.
1670
+ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1671
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1672
+ return null;
1673
+ }
1674
+
1675
+ // A wildcard or sub-minute step second confined to minute 0 is the whole
1676
+ // minute-0 window ("durante un minuto"), confined to the hour range with the
1677
+ // "durante las horas …" idiom — kept distinct from the bare minute-0 window
1678
+ // ("cada hora de las 09:00 a las 17:00") so the confinement is never heard
1679
+ // as it — the hour-range analog of "durante un minuto, durante las horas
1680
+ // pares".
1681
+ if (subMinuteSecond(ir)) {
1682
+ return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1683
+ 'durante las horas ' + hourSegmentTimes(ir, 0, null, opts) +
1684
+ trailingQualifier(ir, opts);
1685
+ }
1686
+
1687
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1688
+ hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1689
+ }
1690
+
1491
1691
  // --- Hour-time phrasing. ---
1492
1692
 
1693
+ // The fixed hour(s) of a stepped/listed minute, named as the HOUR rather than a
1694
+ // "a las HH:00" clock instant the minute never fires at: noon and midnight read
1695
+ // as the hour word ("al mediodía"/"a medianoche"), any other hour as the whole
1696
+ // hour "de la hora de las HH:00" (the idiom a wildcard minute already uses).
1697
+ // Used by the compact-clock non-fold path, where the minute is a step or list
1698
+ // (a single-value minute keeps its real "a las HH:MM" clock time elsewhere).
1699
+ function hourContextTimes(ir: IR, opts: Opts): string {
1700
+ const segments = hourSegments(ir);
1701
+
1702
+ // Collect the point hours (singles and step fires) — a range stays a window.
1703
+ const points: number[] = [];
1704
+ const hasRange = segments.some(function range(segment) {
1705
+ return segment.kind === 'range';
1706
+ });
1707
+
1708
+ segments.forEach(function collect(segment) {
1709
+ if (segment.kind === 'step') {
1710
+ points.push(...segment.fires);
1711
+ }
1712
+ else if (segment.kind === 'single') {
1713
+ points.push(+segment.value);
1714
+ }
1715
+ });
1716
+
1717
+ // All point hours, all noon/midnight: stand alone as their own words ("a
1718
+ // medianoche y al mediodía").
1719
+ function isWord(hour: number): boolean {
1720
+ return !opts.ampm && (hour === 0 || hour === 12);
1721
+ }
1722
+
1723
+ if (!hasRange && points.every(isWord)) {
1724
+ return joinList(points.map(function each(hour) {
1725
+ return atTime(bareHourPhrase(hour, opts));
1726
+ }));
1727
+ }
1728
+
1729
+ // A point hour as the whole hour: "de la hora de las HH:00".
1730
+ function wholeHour(hour: number): string {
1731
+ return 'de la hora ' + fromTime(explicitTimePhrase(hour, 0, opts));
1732
+ }
1733
+
1734
+ // Otherwise each whole hour reads as a window ("de las HH:00 a las HH:00" for
1735
+ // a range, "de la hora de las HH:00" for a point), never a false "a las
1736
+ // HH:00" clock instant the stepped minute never fires at.
1737
+ const pieces: string[] = [];
1738
+
1739
+ segments.forEach(function place(segment) {
1740
+ if (segment.kind === 'range') {
1741
+ pieces.push(timeRange(
1742
+ {hour: +segment.bounds[0], minute: 0},
1743
+ {hour: +segment.bounds[1], minute: 0}, opts));
1744
+ }
1745
+ else if (segment.kind === 'step') {
1746
+ segment.fires.forEach(function each(hour) {
1747
+ pieces.push(wholeHour(hour));
1748
+ });
1749
+ }
1750
+ else {
1751
+ pieces.push(wholeHour(+segment.value));
1752
+ }
1753
+ });
1754
+
1755
+ return joinList(pieces);
1756
+ }
1757
+
1493
1758
  // "a las 9:00" / "a la 1:00" / "al mediodía" for each fire hour.
1494
1759
  function atTimes(hours: number[], opts: Opts): string[] {
1495
1760
  return hours.map(function each(hour) {
@@ -1940,7 +2205,9 @@ function weekdayQualifier(ir: IR): string {
1940
2205
  return quartz;
1941
2206
  }
1942
2207
 
1943
- const segments = flattenSteps(fieldSegments(ir, 'weekday'));
2208
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
2209
+ // form. The IR stays canonical (Sunday=0). The helper flattens steps.
2210
+ const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
1944
2211
  const allSingles = segments.every(function single(segment) {
1945
2212
  return segment.kind === 'single';
1946
2213
  });