cronli5 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lang/zh.cjs CHANGED
@@ -197,7 +197,8 @@ function hourFrame(ir) {
197
197
  return "\u5728" + hourList(ir) + "\uFF0C";
198
198
  }
199
199
  function renderMinuteFrequency(ir, plan) {
200
- const base = cadence(stepSegment(ir, "minute").interval, UNITS.minute);
200
+ const minuteStep = stepSegment(ir, "minute");
201
+ const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
201
202
  const { hours } = plan;
202
203
  if (hours.kind === "step") {
203
204
  return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + base;
@@ -265,9 +266,6 @@ function renderHourStep(ir) {
265
266
  if (segment.fires.length <= 2) {
266
267
  return joinAnd(segment.fires.map(hourWord));
267
268
  }
268
- if (24 % segment.interval !== 0) {
269
- return "\u4ECE" + hourWord(segment.fires[0]) + "\u8D77\uFF0C" + cadence(segment.interval, UNITS.hour);
270
- }
271
269
  return cadence(segment.interval, UNITS.hour);
272
270
  }
273
271
  function renderRangeOfMinutes(ir) {
@@ -320,15 +318,12 @@ function composeSecondsOnHour(ir, plan, opts) {
320
318
  const sec = secondClause(ir);
321
319
  const { rest } = plan;
322
320
  const restText = render(ir, rest, opts);
323
- if (rest.kind === "everyHour") {
324
- return sec + "\uFF0C\u6BCF\u5C0F\u65F6";
325
- }
326
- if (rest.kind === "hourStep") {
327
- return sec + "\uFF0C" + restText;
328
- }
329
321
  if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && isDaily(ir)) {
330
322
  return "\u6BCF\u5929" + restText + sec;
331
323
  }
324
+ if (rest.kind === "singleMinute") {
325
+ return restText + "\uFF0C" + sec;
326
+ }
332
327
  return restText + sec;
333
328
  }
334
329
  function composeSecondsCadence(ir) {
@@ -348,17 +343,12 @@ function composeSecondsCadence(ir) {
348
343
  function composeSecondsListed(ir) {
349
344
  const sec = secondClause(ir);
350
345
  const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
351
- const minuteSegs = fieldSegments(ir, "minute");
352
346
  if (ir.shapes.hour === "wildcard") {
353
347
  return minutes + "\uFF0C" + sec;
354
348
  }
355
349
  if (isHourCadence(ir)) {
356
350
  return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + "\uFF0C" + minutes + "\uFF0C" + sec;
357
351
  }
358
- if (ir.shapes.hour === "range" && minuteSegs.length === 1 && minuteSegs[0].kind === "range") {
359
- const [from, to] = fieldSegments(ir, "hour")[0].bounds;
360
- return "\u5728" + hourWord(+from) + "\u81F3" + to + "\u70B9" + minuteSegs[0].bounds[1] + "\u5206\u4E4B\u95F4\uFF0C" + sec;
361
- }
362
352
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
363
353
  }
364
354
  function renderComposeSeconds(ir, plan, opts) {
package/dist/lang/zh.js CHANGED
@@ -171,7 +171,8 @@ function hourFrame(ir) {
171
171
  return "\u5728" + hourList(ir) + "\uFF0C";
172
172
  }
173
173
  function renderMinuteFrequency(ir, plan) {
174
- const base = cadence(stepSegment(ir, "minute").interval, UNITS.minute);
174
+ const minuteStep = stepSegment(ir, "minute");
175
+ const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
175
176
  const { hours } = plan;
176
177
  if (hours.kind === "step") {
177
178
  return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + base;
@@ -239,9 +240,6 @@ function renderHourStep(ir) {
239
240
  if (segment.fires.length <= 2) {
240
241
  return joinAnd(segment.fires.map(hourWord));
241
242
  }
242
- if (24 % segment.interval !== 0) {
243
- return "\u4ECE" + hourWord(segment.fires[0]) + "\u8D77\uFF0C" + cadence(segment.interval, UNITS.hour);
244
- }
245
243
  return cadence(segment.interval, UNITS.hour);
246
244
  }
247
245
  function renderRangeOfMinutes(ir) {
@@ -294,15 +292,12 @@ function composeSecondsOnHour(ir, plan, opts) {
294
292
  const sec = secondClause(ir);
295
293
  const { rest } = plan;
296
294
  const restText = render(ir, rest, opts);
297
- if (rest.kind === "everyHour") {
298
- return sec + "\uFF0C\u6BCF\u5C0F\u65F6";
299
- }
300
- if (rest.kind === "hourStep") {
301
- return sec + "\uFF0C" + restText;
302
- }
303
295
  if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && isDaily(ir)) {
304
296
  return "\u6BCF\u5929" + restText + sec;
305
297
  }
298
+ if (rest.kind === "singleMinute") {
299
+ return restText + "\uFF0C" + sec;
300
+ }
306
301
  return restText + sec;
307
302
  }
308
303
  function composeSecondsCadence(ir) {
@@ -322,17 +317,12 @@ function composeSecondsCadence(ir) {
322
317
  function composeSecondsListed(ir) {
323
318
  const sec = secondClause(ir);
324
319
  const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
325
- const minuteSegs = fieldSegments(ir, "minute");
326
320
  if (ir.shapes.hour === "wildcard") {
327
321
  return minutes + "\uFF0C" + sec;
328
322
  }
329
323
  if (isHourCadence(ir)) {
330
324
  return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + "\uFF0C" + minutes + "\uFF0C" + sec;
331
325
  }
332
- if (ir.shapes.hour === "range" && minuteSegs.length === 1 && minuteSegs[0].kind === "range") {
333
- const [from, to] = fieldSegments(ir, "hour")[0].bounds;
334
- return "\u5728" + hourWord(+from) + "\u81F3" + to + "\u70B9" + minuteSegs[0].bounds[1] + "\u5206\u4E4B\u95F4\uFF0C" + sec;
335
- }
336
326
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
337
327
  }
338
328
  function renderComposeSeconds(ir, plan, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -269,10 +269,13 @@ function planSeconds(
269
269
  return null;
270
270
  }
271
271
 
272
+ // The second makes the cadence sub-minute, so a minute of 0 is a real
273
+ // restriction that must be stated, not absorbed into an hourly idiom (which
274
+ // would silently drop it). Route minute 0 to the minute-explicit forms.
272
275
  return {
273
276
  kind: 'composeSeconds',
274
- rest: planMinutes(pattern, shapes, analyses) ||
275
- planHours(pattern, shapes, analyses)
277
+ rest: planMinutes(pattern, shapes, analyses, true) ||
278
+ planHours(pattern, shapes, analyses, true)
276
279
  };
277
280
  }
278
281
 
@@ -303,7 +306,8 @@ function planStandaloneSeconds(
303
306
  function planMinutes(
304
307
  pattern: Pattern,
305
308
  shapes: Shapes,
306
- analyses: Analyses
309
+ analyses: Analyses,
310
+ subMinuteSecond = false
307
311
  ): PlanNode | undefined {
308
312
  if (shapes.minute === 'step') {
309
313
  return {
@@ -333,7 +337,7 @@ function planMinutes(
333
337
  }
334
338
 
335
339
  if (pattern.hour === '*') {
336
- return planMinutesUnderOpenHour(pattern, shapes);
340
+ return planMinutesUnderOpenHour(pattern, shapes, subMinuteSecond);
337
341
  }
338
342
  }
339
343
 
@@ -451,7 +455,8 @@ function planMinutesAcrossHours(
451
455
  // Minute strategies that only stand on their own under a wildcard hour.
452
456
  function planMinutesUnderOpenHour(
453
457
  pattern: Pattern,
454
- shapes: Shapes
458
+ shapes: Shapes,
459
+ subMinuteSecond: boolean
455
460
  ): PlanNode | undefined {
456
461
  if (shapes.minute === 'range') {
457
462
  return {kind: 'rangeOfMinutes'};
@@ -465,56 +470,89 @@ function planMinutesUnderOpenHour(
465
470
  return {kind: 'everyMinute'};
466
471
  }
467
472
 
468
- if (pattern.minute !== '0') {
473
+ // Minute 0 normally defers to "every hour" so a standalone `0 * * * *`
474
+ // stays terse; under a sub-minute second it must be stated, so name it.
475
+ if (pattern.minute !== '0' || subMinuteSecond) {
469
476
  return {kind: 'singleMinute'};
470
477
  }
471
478
  }
472
479
 
473
- // Hour strategies: the chain's last resort always produces a plan.
480
+ // Hour strategies: the chain's last resort always produces a plan. Under a
481
+ // sub-minute second a minute of 0 is a real restriction, so the absorbing
482
+ // idioms (hour range, hour step, every hour) are skipped for it and the hour
483
+ // is enumerated as clock times instead, stating the :00.
474
484
  function planHours(
475
485
  pattern: Pattern,
476
486
  shapes: Shapes,
477
- analyses: Analyses
487
+ analyses: Analyses,
488
+ subMinuteSecond = false
478
489
  ): PlanNode {
479
- if (shapes.hour === 'range') {
480
- const bounds = pattern.hour.split('-');
481
- let minuteForm: 'lead' | 'wildcard' | 'range' = 'lead';
482
-
483
- if (pattern.minute === '*') {
484
- minuteForm = 'wildcard';
485
- }
486
- else if (shapes.minute === 'range') {
487
- minuteForm = 'range';
488
- }
490
+ const absorbsMinuteZero = subMinuteSecond && pattern.minute === '0';
489
491
 
490
- return {
491
- from: +bounds[0],
492
- kind: 'hourRange',
493
- last: analyses.lastMinuteFire,
494
- minuteForm,
495
- to: +bounds[1]
496
- };
492
+ if (shapes.hour === 'range' && !absorbsMinuteZero) {
493
+ return planHourRange(pattern, shapes, analyses);
497
494
  }
498
495
 
499
- if (shapes.hour === 'step' && pattern.minute === '0') {
496
+ if (shapes.hour === 'step' && pattern.minute === '0' && !subMinuteSecond) {
500
497
  return {kind: 'hourStep'};
501
498
  }
502
499
 
503
- if (pattern.hour === '*') {
500
+ if (pattern.hour === '*' && !absorbsMinuteZero) {
504
501
  return {kind: 'everyHour'};
505
502
  }
506
503
 
507
- return planClockTimes(pattern, analyses);
504
+ // When minute 0 must be stated, enumerate the on-the-hour times explicitly:
505
+ // the compact fold of a contiguous hour range would otherwise restate the
506
+ // hour-range idiom ("every hour from X through Y") and re-drop the :00.
507
+ return planClockTimes(pattern, analyses, absorbsMinuteZero);
508
+ }
509
+
510
+ // The hour-range plan: a window from the first hour through the last. The
511
+ // minute clause leads (a single fire or a list), fires every minute (a range),
512
+ // or fills the window (a wildcard). A multi-valued minute (list or range)
513
+ // closes the window on the bare hour, stating its minutes separately; a single
514
+ // fire or a wildcard names an exact closing minute (its fire, or the wildcard's
515
+ // last :59) — otherwise the glued last fire reads as a continuous span.
516
+ function planHourRange(
517
+ pattern: Pattern,
518
+ shapes: Shapes,
519
+ analyses: Analyses
520
+ ): PlanNode {
521
+ const bounds = pattern.hour.split('-');
522
+ let minuteForm: 'lead' | 'wildcard' | 'range' = 'lead';
523
+
524
+ if (pattern.minute === '*') {
525
+ minuteForm = 'wildcard';
526
+ }
527
+ else if (shapes.minute === 'range') {
528
+ minuteForm = 'range';
529
+ }
530
+
531
+ const multiValued = shapes.minute === 'range' || shapes.minute === 'list';
532
+
533
+ return {
534
+ boundMinute: multiValued ? null : analyses.lastMinuteFire,
535
+ from: +bounds[0],
536
+ kind: 'hourRange',
537
+ last: analyses.lastMinuteFire,
538
+ minuteForm,
539
+ to: +bounds[1]
540
+ };
508
541
  }
509
542
 
510
543
  // Enumerated clock times up to the cap; past it, a compact form (a single
511
544
  // minute folds into hour-segment windows; a minute list leads with its own
512
- // clause).
513
- function planClockTimes(pattern: Pattern, analyses: Analyses): PlanNode {
545
+ // clause). `enumerate` forces the explicit list past the cap, used when a
546
+ // minute restriction must be named rather than folded into an hour idiom.
547
+ function planClockTimes(
548
+ pattern: Pattern,
549
+ analyses: Analyses,
550
+ enumerate = false
551
+ ): PlanNode {
514
552
  const hours = enumerateFires(pattern.hour, 0, 23);
515
553
  const minutes = enumerateValues(pattern.minute);
516
554
 
517
- if (hours.length * minutes.length > maxClockTimes) {
555
+ if (!enumerate && hours.length * minutes.length > maxClockTimes) {
518
556
  return {
519
557
  fold: minutes.length === 1,
520
558
  kind: 'compactClockTimes',
package/src/core/ir.ts CHANGED
@@ -78,6 +78,12 @@ export type PlanNode =
78
78
  from: number;
79
79
  to: number;
80
80
  last: number;
81
+ // The minute to show on the closing bound, or `null` to close on the
82
+ // bare hour with the minutes stated separately. A single fire or a
83
+ // wildcard names an exact closing minute (the fire, or `:59`); a minute
84
+ // list or range would otherwise glue its last fire onto the bound and
85
+ // read as a continuous span, so it closes bare instead.
86
+ boundMinute: number | null;
81
87
  minuteForm: 'lead' | 'wildcard' | 'range';
82
88
  }
83
89
  | {kind: 'hourStep'}
@@ -4,10 +4,20 @@
4
4
 
5
5
  import {fieldOrder, fieldSpecs} from './specs.js';
6
6
  import type {CronLike, FieldSpec} from './specs.js';
7
- import type {Pattern} from './ir.js';
7
+ import type {Field, Pattern} from './ir.js';
8
8
  import {includes, toFieldNumber, unique} from './util.js';
9
9
  import {isQuartzDate, isQuartzWeekday} from './validate.js';
10
10
 
11
+ // The fixed-cycle time fields: their step intervals are measured against a
12
+ // closed cycle (60 seconds, 60 minutes, 24 hours), so a step is a true
13
+ // "every N" cadence only when it tiles that cycle. The calendar fields
14
+ // (date/month/weekday) have variable cycles and keep their step form.
15
+ const timeFieldCycle: Partial<Record<Field, number>> = {
16
+ hour: 24,
17
+ minute: 60,
18
+ second: 60
19
+ };
20
+
11
21
  // Quartz aliases: `?` reads "no specific value" (equivalent to `*`) in the
12
22
  // date and weekday fields, and a bare `L` weekday means Saturday.
13
23
  function applyQuartzAliases(cronPattern: CronLike): void {
@@ -40,7 +50,7 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
40
50
  return;
41
51
  }
42
52
 
43
- cronPattern[field] = normalizeField(value, fieldSpecs[field]);
53
+ cronPattern[field] = normalizeField(value, field, fieldSpecs[field]);
44
54
  });
45
55
 
46
56
  // Every field is now a canonical string.
@@ -48,17 +58,20 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
48
58
  }
49
59
 
50
60
  // Canonicalize a single validated field value to a string.
51
- function normalizeField(value: string, spec: FieldSpec): string {
61
+ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
52
62
  const stringValue = '' + value;
53
63
 
54
64
  if (stringValue === '*') {
55
65
  return stringValue;
56
66
  }
57
67
 
68
+ const cycle = timeFieldCycle[field];
58
69
  const segments = stringValue.split(',').map(function canonical(segment) {
59
- return collapseDegenerateRange(
60
- collapseOnceStep(collapseUnitStep(segment, spec), spec), spec);
61
- });
70
+ return enumerateNonUniformStep(
71
+ collapseDegenerateRange(
72
+ collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
73
+ spec, cycle);
74
+ }).join(',').split(',');
62
75
 
63
76
  // A full-cycle segment covers the whole field.
64
77
  if (segments.indexOf('*') !== -1) {
@@ -115,6 +128,41 @@ function collapseOnceStep(segment: string, spec: FieldSpec): string {
115
128
  return start === '*' ? '' + spec.min : start;
116
129
  }
117
130
 
131
+ // An unbounded step in a fixed-cycle time field is a true "every N" cadence
132
+ // only when it tiles the cycle: the interval divides it evenly and the start
133
+ // falls within the first interval (`*/15`, `5/6`). A step that fails either
134
+ // test fires at irregular points within the cycle, so it reads as the literal
135
+ // list of those fires (`*/7` is `0,7,14,…`), the same as if it were written
136
+ // out. Calendar fields (no `cycle`), bounded steps (`9-17/2`, a per-window
137
+ // stride), and non-step segments are left untouched.
138
+ function enumerateNonUniformStep(
139
+ segment: string,
140
+ spec: FieldSpec,
141
+ cycle: number | undefined
142
+ ): string {
143
+ const parts = segment.split('/');
144
+
145
+ if (typeof cycle !== 'number' || parts.length !== 2 ||
146
+ includes(parts[0], '-')) {
147
+ return segment;
148
+ }
149
+
150
+ const interval = +parts[1];
151
+ const start = parts[0] === '*' ? spec.min : toFieldNumber(parts[0]);
152
+
153
+ if (cycle % interval === 0 && start < interval) {
154
+ return segment;
155
+ }
156
+
157
+ const fires = [];
158
+
159
+ for (let value = start; value <= (spec.top as number); value += interval) {
160
+ fires.push(value);
161
+ }
162
+
163
+ return fires.join(',');
164
+ }
165
+
118
166
  // A degenerate range (`9-9`) fires once, so it reads as its single value.
119
167
  // A stepped degenerate range (`9-9/5`) likewise fires only at its start.
120
168
  function collapseDegenerateRange(segment: string, spec: FieldSpec): string {
@@ -462,7 +462,9 @@ function duringHours(ir: IR, times: HourTimesPlan, sep: string): string {
462
462
  return joinList(windows);
463
463
  }
464
464
 
465
- return 'in den Stunden von ' + joinList(times.fires.map(String)) + ' Uhr';
465
+ // A discrete set of hours is a list, not a range, so it takes no "von"
466
+ // (which would read as "von X bis Y"); it mirrors the minute list form.
467
+ return 'in den Stunden ' + joinList(times.fires.map(String)) + ' Uhr';
466
468
  }
467
469
 
468
470
  // --- Renderers. ---
@@ -657,7 +659,11 @@ function renderHourRange(
657
659
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
658
660
  opts: Opts
659
661
  ): string {
660
- const window = hourWindow(plan.from, plan.to, plan.last, opts.style.sep);
662
+ // A bare close (`boundMinute` null) lands on the top of the final hour
663
+ // (minute 0), matching the minute-0 baseline, with the minutes stated
664
+ // separately; a single fire or wildcard names an exact closing minute.
665
+ const window = hourWindow(plan.from, plan.to, plan.boundMinute ?? 0,
666
+ opts.style.sep);
661
667
 
662
668
  if (plan.minuteForm === 'wildcard') {
663
669
  return 'jede Minute ' + window;
@@ -814,7 +820,10 @@ const de: Language<GermanStyle> = {
814
820
  fallback: 'ein unlesbares Cron-Muster',
815
821
  options: normalizeOptions,
816
822
  reboot: 'beim Systemstart',
817
- sentence: (description) => 'Läuft ' + description + '.'
823
+ // A description ending in a German ordinal already carries its period
824
+ // ("…am 8."), so closing the sentence must not double it.
825
+ sentence: (description) =>
826
+ 'Läuft ' + description + (description.endsWith('.') ? '' : '.')
818
827
  };
819
828
 
820
829
  export default de;
@@ -376,7 +376,7 @@ function renderEveryHour(ir: IR, plan: PlanOf<'everyHour'>,
376
376
  // minute; a discrete minute anchors as a lead clause.
377
377
  function renderHourRange(ir: IR, plan: PlanOf<'hourRange'>,
378
378
  opts: NormalizedOptions): string {
379
- const window = hourWindow(plan, opts);
379
+ const window = hourWindow(boundedWindow(plan), opts);
380
380
 
381
381
  if (plan.minuteForm === 'wildcard') {
382
382
  return 'every minute ' + window + trailingQualifier(ir, opts);
@@ -411,6 +411,14 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
411
411
  trailingQualifier(ir, opts);
412
412
  }
413
413
 
414
+ // The hour-range plan as a window whose closing minute honors `boundMinute`:
415
+ // a bare close (`null`) lands on the top of the final hour (`:00`), matching
416
+ // the minute-0 baseline, with the minutes stated separately elsewhere.
417
+ function boundedWindow(plan: PlanOf<'hourRange'>):
418
+ {from: number; to: number; last: number} {
419
+ return {from: plan.from, last: plan.boundMinute ?? 0, to: plan.to};
420
+ }
421
+
414
422
  // An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.". Windows
415
423
  // open at the top of the first hour and close at the minute field's last
416
424
  // fire within the final hour.
@@ -539,7 +547,10 @@ const renderers = {
539
547
  // Phrase a `start/interval` step segment for a field that cycles every 60
540
548
  // units (seconds and minutes). `unit` is the singular noun and `anchor` is
541
549
  // the larger unit the values are counted against. Interval-one steps never
542
- // arrive here: normalization collapses them to ranges or `*`.
550
+ // arrive here: normalization collapses them to ranges or `*`. Nor do uneven
551
+ // steps that fail to tile the cycle: normalization rewrites those to the
552
+ // literal list of their fires, so only a clean cadence (interval dividing
553
+ // 60, start within the first interval) reaches a step renderer.
543
554
  function stepCycle60(segment: StepSegment, unit: string,
544
555
  anchor: string, opts: NormalizedOptions): string {
545
556
  // A bounded start (`a-b/n`) applies the interval within the range.
@@ -551,6 +562,8 @@ function stepCycle60(segment: StepSegment, unit: string,
551
562
  const interval = segment.interval;
552
563
 
553
564
  if (start !== 0) {
565
+ // A short offset cadence lists its fires; a longer one names the
566
+ // interval and its starting offset ("every six minutes from five …").
554
567
  if (segment.fires.length <= 3) {
555
568
  return listPastThe(numberWords(segment.fires, opts), unit, anchor,
556
569
  opts);
@@ -561,18 +574,8 @@ function stepCycle60(segment: StepSegment, unit: string,
561
574
  ' past the ' + anchor;
562
575
  }
563
576
 
564
- // A step reads as a natural cadence ("every N minutes") only when it
565
- // divides the cycle evenly, mirroring the hour field's `24 % n` rule.
566
- if (60 % interval === 0) {
567
- return 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
568
- }
569
-
570
- if (segment.fires.length <= 2) {
571
- return listPastThe(numberWords(segment.fires, opts), unit, anchor, opts);
572
- }
573
-
574
- return 'every ' + getNumber(interval, opts) + ' ' + unit +
575
- 's past the ' + anchor;
577
+ // A clean stride from the top of the cycle is the bare cadence.
578
+ return 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
576
579
  }
577
580
 
578
581
  // Phrase a `start/interval` step segment for the hour field (cycles every
@@ -586,18 +589,18 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
586
589
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
587
590
  const interval = segment.interval;
588
591
 
589
- if (start === 0 && 24 % interval === 0) {
592
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
593
+ // rewritten to its fires upstream and never reaches here.)
594
+ if (start === 0) {
590
595
  return 'every ' + getNumber(interval, opts) + ' hours';
591
596
  }
592
597
 
598
+ // A short offset cadence lists its fires; a longer one names the interval
599
+ // and its start ("every three hours from 2 a.m.").
593
600
  if (segment.fires.length <= 3) {
594
601
  return 'at ' + hourTimes(segment.fires, opts);
595
602
  }
596
603
 
597
- if (start === 0) {
598
- return 'every ' + getNumber(interval, opts) + ' hours from midnight';
599
- }
600
-
601
604
  return 'every ' + getNumber(interval, opts) + ' hours from ' +
602
605
  getTime({hour: start, minute: 0}, opts);
603
606
  }
@@ -525,7 +525,7 @@ function renderHourRange(
525
525
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
526
526
  opts: Opts
527
527
  ): string {
528
- const window = hourWindow(plan, opts);
528
+ const window = hourWindow(boundedWindow(plan), opts);
529
529
 
530
530
  if (plan.minuteForm === 'wildcard') {
531
531
  return 'cada minuto ' + window + trailingQualifier(ir, opts);
@@ -558,6 +558,15 @@ function renderHourStep(
558
558
  trailingQualifier(ir, opts);
559
559
  }
560
560
 
561
+ // The hour-range plan as a window whose closing minute honors `boundMinute`:
562
+ // a bare close (`null`) lands on the top of the final hour (minute 0),
563
+ // matching the minute-0 baseline, with the minutes stated separately.
564
+ function boundedWindow(
565
+ plan: Extract<PlanNode, {kind: 'hourRange'}>
566
+ ): {from: number; to: number; last: number} {
567
+ return {from: plan.from, last: plan.boundMinute ?? 0, to: plan.to};
568
+ }
569
+
561
570
  // "de las 9:00 a las 17:45": a window from the top of the first hour to
562
571
  // the minute field's last fire within the final hour.
563
572
  function hourWindow(
@@ -1006,17 +1015,9 @@ function stepCycle60(
1006
1015
  unit + ' ' + start + ' de cada ' + anchor;
1007
1016
  }
1008
1017
 
1009
- if (60 % interval === 0) {
1010
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1011
- }
1012
-
1013
- if (segment.fires.length <= 2) {
1014
- return 'en los ' + unit + 's ' + joinList(wordList(segment.fires)) +
1015
- ' de cada ' + anchor;
1016
- }
1017
-
1018
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's de cada ' +
1019
- anchor;
1018
+ // A clean stride from the top of the cycle is the bare cadence. (An uneven
1019
+ // stride is rewritten to its fires upstream and never reaches here.)
1020
+ return 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1020
1021
  }
1021
1022
 
1022
1023
  // "cada seis horas", "a las 9:00, a las 11:00 y a la 1:00", or "cada
@@ -1029,7 +1030,9 @@ function stepHours(segment: StepSegment, opts: Opts): string {
1029
1030
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
1030
1031
  const interval = segment.interval;
1031
1032
 
1032
- if (start === 0 && 24 % interval === 0) {
1033
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
1034
+ // rewritten to its fires upstream and never reaches here.)
1035
+ if (start === 0) {
1033
1036
  return 'cada ' + numero(interval, opts) + ' horas';
1034
1037
  }
1035
1038
 
@@ -1037,10 +1040,6 @@ function stepHours(segment: StepSegment, opts: Opts): string {
1037
1040
  return groupClockTimesByArticle(atTimes(segment.fires, opts));
1038
1041
  }
1039
1042
 
1040
- if (start === 0) {
1041
- return 'cada ' + numero(interval, opts) + ' horas desde medianoche';
1042
- }
1043
-
1044
1043
  return 'cada ' + numero(interval, opts) + ' horas a partir de ' +
1045
1044
  timePhrase(start, 0, null, opts);
1046
1045
  }