cronli5 0.1.2 → 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.
@@ -3,6 +3,8 @@
3
3
  // the core stays semantic, and this module's only input is the IR.
4
4
  // See docs/i18n-design.md.
5
5
 
6
+ import {arithmeticStep} from '../../core/util.js';
7
+ import {maxClockTimes} from '../../core/specs.js';
6
8
  import {clockDigits, numeral} from '../../core/format.js';
7
9
  import type {Cronli5Options} from '../../types.js';
8
10
  import type {
@@ -19,14 +21,29 @@ type PlanOf<K extends PlanNode['kind']> = Extract<PlanNode, {kind: K}>;
19
21
  // phrasing, where the first segment is always a step segment.
20
22
  type StepSegment = Extract<Segment, {kind: 'step'}>;
21
23
 
24
+ // A step cadence to phrase: the `interval` repeats over a `cycle`-long field
25
+ // (60 for minute/second, 24 for hour), running from `start` to `last`. `unit`
26
+ // is the singular noun and `anchor` the larger unit the values count against.
27
+ interface Stride {
28
+ interval: number;
29
+ start: number;
30
+ last: number;
31
+ cycle: number;
32
+ unit: string;
33
+ anchor: string;
34
+ }
35
+
22
36
  // A clock-time entry assembled for rendering. Hour/minute/second arrive as
23
37
  // numbers or as raw field tokens (a range bound or single value is a
24
- // string); `plain` suppresses the noon/midnight words.
38
+ // string); `plain` suppresses the noon/midnight words. `explicit` forces the
39
+ // minute to show even when zero ("9:00 a.m.", not "9 a.m.") and suppresses
40
+ // the noon/midnight words, so a pinned minute-0 stays visible.
25
41
  interface TimeEntry {
26
42
  hour: number | string;
27
43
  minute: number | string;
28
44
  second?: number | string | null;
29
45
  plain?: boolean;
46
+ explicit?: boolean;
30
47
  }
31
48
 
32
49
  // English number names for the integers zero through ten.
@@ -80,22 +97,6 @@ const weekdayNames: [string, string][] = [
80
97
  ['Saturday', 'Sat']
81
98
  ];
82
99
 
83
- // Month names by abbreviation.
84
- const monthAbbreviations: Record<string, [string, string] | null> = {
85
- JAN: monthNames[1],
86
- FEB: monthNames[2],
87
- MAR: monthNames[3],
88
- APR: monthNames[4],
89
- MAY: monthNames[5],
90
- JUN: monthNames[6],
91
- JUL: monthNames[7],
92
- AUG: monthNames[8],
93
- SEP: monthNames[9],
94
- OCT: monthNames[10],
95
- NOV: monthNames[11],
96
- DEC: monthNames[12]
97
- };
98
-
99
100
  // Weekday name by abbreviation.
100
101
  const weekdayAbbreviations: Record<string, [string, string]> = {
101
102
  SUN: weekdayNames[0],
@@ -187,18 +188,121 @@ function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
187
188
  trailingQualifier(ir, opts);
188
189
  }
189
190
 
191
+ // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
192
+ // would cross-multiply an hour stride under a single pinned minute, or null
193
+ // when that does not apply (a non-clock rest, a multi-valued minute, or an
194
+ // hour that is not a stride).
195
+ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
196
+ opts: NormalizedOptions): string | null {
197
+ const clockRest = plan.rest.kind === 'clockTimes' ||
198
+ plan.rest.kind === 'compactClockTimes';
199
+
200
+ return clockRest && ir.shapes.minute === 'single' ?
201
+ hourCadence(ir, +ir.pattern.minute, opts) :
202
+ null;
203
+ }
204
+
190
205
  // A meaningful second under minute/hour shapes the earlier strategies
191
206
  // deferred on: the second leads with its own clause and the rest of the
192
207
  // pattern follows.
193
208
  function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
194
209
  opts: NormalizedOptions): string {
210
+ // An hour step (or arithmetic-progression hour list) under a single pinned
211
+ // minute is a cadence, not a wall of clock times: speak the second/minute
212
+ // lead, then the hour cadence ("at 30 seconds past the hour, every two
213
+ // hours"). The clock-time rest would otherwise cross-multiply the hours.
214
+ const cadence = composeHourCadence(ir, plan, opts);
215
+
216
+ if (cadence !== null) {
217
+ return cadence;
218
+ }
219
+
220
+ // A wildcard or stepped second under a minute pinned to a single value
221
+ // across one or more specific hours. The clock-time rest collapses the
222
+ // pinned minute into the hour, and on the clock a pinned minute-0 reads as
223
+ // the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
224
+ // confinement. (A second list/range/single leads with a "past the minute"
225
+ // clause that an "of"/duration frame cannot follow, so it stays generic.)
226
+ if (plan.rest.kind === 'clockTimes' &&
227
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
228
+ const minute = plan.rest.times[0].minute;
229
+
230
+ // Minute 0 is the one-minute window at the top of each named hour: a
231
+ // duration frame ("for one minute at 9 a.m.") states the confinement
232
+ // outright, with the hour as its word so it cannot be heard as the hour
233
+ // itself. A non-zero pinned minute is an unambiguous clock time, so the
234
+ // compact "of 9:05 a.m." form reads it as the minute, never the hour.
235
+ if (+minute === 0) {
236
+ return secondsLeadClause(ir, opts) + ' for one minute at ' +
237
+ durationHours(ir, plan.rest, opts);
238
+ }
239
+
240
+ return secondsLeadClause(ir, opts) + ' of ' +
241
+ clockTimesOf(ir, plan.rest, opts);
242
+ }
243
+
244
+ // A wildcard second under a */2 minute step with a wildcard hour binds
245
+ // idiomatically as "every second of every other minute": "every other" is
246
+ // the natural English for an interval of 2, and "of" joins the two without
247
+ // the ambiguity of a comma, which reads as two independent cadences.
248
+ // Scoped to */2 only; other step sizes keep the comma form.
249
+ if (ir.shapes.second === 'wildcard' &&
250
+ plan.rest.kind === 'minuteFrequency' &&
251
+ plan.rest.hours.kind === 'none' &&
252
+ ir.pattern.minute === '*/2') {
253
+ return 'every second of every other minute' +
254
+ trailingQualifier(ir, opts);
255
+ }
256
+
195
257
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
196
258
  }
197
259
 
260
+ // The bare-hour words for a minute-0 duration confinement, joined and followed
261
+ // by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
262
+ // 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
263
+ // never "H:00", since the "for one minute" frame already carries the minute.
264
+ function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
265
+ opts: NormalizedOptions): string {
266
+ const hours = plan.times.map(function clock(time) {
267
+ return getTime({hour: time.hour, minute: 0}, opts);
268
+ });
269
+ const trail = dayQualifier(ir, leadingWords, opts);
270
+
271
+ return joinList(hours, opts) + (trail && ', ' + trail);
272
+ }
273
+
274
+ // The clock times for a non-zero pinned-minute compose-seconds rest, joined
275
+ // and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
276
+ // every day". The non-zero minute reads as a clock time, never the hour.
277
+ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
278
+ opts: NormalizedOptions): string {
279
+ const times = plan.times.map(function clock(time) {
280
+ return getTime({
281
+ hour: time.hour,
282
+ minute: time.minute,
283
+ second: time.second,
284
+ explicit: true
285
+ }, opts);
286
+ });
287
+ const trail = dayQualifier(ir, leadingWords, opts);
288
+
289
+ return joinList(times, opts) + (trail && ', ' + trail);
290
+ }
291
+
198
292
  // The leading clause describing a second field relative to the minute,
199
293
  // e.g. "at 5 and 10 seconds past the minute" or "every second from zero
200
294
  // through 30 past the minute".
201
295
  function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
296
+ return secondsClause(ir, 'minute', opts);
297
+ }
298
+
299
+ // The second clause counted against an arbitrary anchor. The anchor is
300
+ // "minute" in the standalone seconds path; the hour-cadence path folds a
301
+ // pinned minute 0 into the hour and counts the second "past the hour"
302
+ // instead ("at 30 seconds past the hour", "every second from 0 through 10
303
+ // past the hour"), so the minute-0 confinement is stated, not dropped.
304
+ function secondsClause(ir: IR, anchor: string,
305
+ opts: NormalizedOptions): string {
202
306
  const secondField = ir.pattern.second;
203
307
  const shape = ir.shapes.second;
204
308
 
@@ -210,7 +314,7 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
210
314
  // The plan reached this clause only for a stepped second field, whose
211
315
  // first segment is always a step segment.
212
316
  return stepCycle60(ir.analyses.segments.second![0] as StepSegment,
213
- 'second', 'minute', opts);
317
+ 'second', anchor, opts);
214
318
  }
215
319
 
216
320
  if (shape === 'range') {
@@ -218,17 +322,20 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
218
322
  const num = seriesNumber(bounds, opts);
219
323
 
220
324
  return 'every second from ' + num(bounds[0]) +
221
- through(opts) + num(bounds[1]) + ' past the minute';
325
+ through(opts) + num(bounds[1]) + ' past the ' + anchor;
222
326
  }
223
327
 
224
328
  if (shape === 'single') {
225
329
  return 'at ' + getNumber(secondField, opts) + ' ' +
226
- pluralize(secondField, 'second') + ' past the minute';
330
+ pluralize(secondField, 'second') + ' past the ' + anchor;
227
331
  }
228
332
 
229
- // A non-wildcard second under the list/step path always has segments.
230
- return listPastThe(segmentWords(ir.analyses.segments.second!, opts),
231
- 'second', 'minute', opts);
333
+ // A non-wildcard second under the list/step path always has segments. An
334
+ // offset/uneven step the core enumerated to a fire list reads as a stride
335
+ // cadence when those fires form a long-enough progression.
336
+ return strideFromSegments(ir.analyses.segments.second!, 'second', anchor,
337
+ opts) ?? listPastThe(segmentWords(ir.analyses.segments.second!, opts),
338
+ 'second', anchor, opts);
232
339
  }
233
340
 
234
341
  // --- Minute renderers. ---
@@ -256,9 +363,13 @@ function renderRangeOfMinutes(ir: IR, plan: PlanOf<'rangeOfMinutes'>,
256
363
  function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
257
364
  opts: NormalizedOptions): string {
258
365
  // A multiple-minutes plan is selected only for a minute list, which has
259
- // segments.
260
- return listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
261
- 'minute', 'hour', opts) + trailingQualifier(ir, opts);
366
+ // segments. An offset/uneven step the core enumerated to this list reads as
367
+ // a stride cadence when the fires form a long-enough progression.
368
+ const stride =
369
+ strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts);
370
+
371
+ return (stride ?? listPastThe(segmentWords(ir.analyses.segments.minute!,
372
+ opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
262
373
  }
263
374
 
264
375
  // A repeating minute step, qualified by the active hour window(s).
@@ -289,9 +400,18 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
289
400
  }
290
401
 
291
402
  // A minute wildcard or plain range under a single specific hour fires
292
- // every minute within a window inside that hour.
403
+ // every minute within a window inside that hour. A wildcard minute is the
404
+ // whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
405
+ // hour") rather than a synthesized "from H:00 through H:59" range the source
406
+ // never stated; a plain range is a real window and keeps "from … through …".
293
407
  function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
294
408
  opts: NormalizedOptions): string {
409
+ if (ir.pattern.minute === '*') {
410
+ return 'every minute of the ' +
411
+ getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
412
+ trailingQualifier(ir, opts);
413
+ }
414
+
295
415
  return 'every minute from ' +
296
416
  getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
297
417
  through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -311,9 +431,11 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
311
431
  const times = hourTimesFromPlan(ir, plan.times, true, opts);
312
432
  const lead = plan.form === 'range' ?
313
433
  minuteRangeLead(ir.pattern.minute, opts) :
314
- // The 'list' form is a minute list, which has segments.
315
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
316
- 'minute', 'hour', opts);
434
+ // The 'list' form is a minute list, which has segments; an offset/uneven
435
+ // step enumerated to that list reads as a stride.
436
+ strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
437
+ listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
438
+ 'minute', 'hour', opts);
317
439
 
318
440
  return lead + ', at ' + times + trailingQualifier(ir, opts);
319
441
  }
@@ -349,8 +471,15 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
349
471
  trailingQualifier(ir, opts);
350
472
  }
351
473
 
352
- return minuteRangeLead(ir.pattern.minute, opts) + ', ' +
353
- stepHours(segment, opts) + trailingQualifier(ir, opts);
474
+ // A minute list keeps the same cadence clause; only its lead differs. An
475
+ // offset/uneven step the core enumerated to that list reads as a stride.
476
+ const lead = plan.form === 'list' ?
477
+ strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
478
+ listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
479
+ 'minute', 'hour', opts) :
480
+ minuteRangeLead(ir.pattern.minute, opts);
481
+
482
+ return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
354
483
  }
355
484
 
356
485
  // Lead phrase for a plain minute range: "every minute from <a> through <b>
@@ -398,8 +527,10 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
398
527
  return 'every hour';
399
528
  }
400
529
 
401
- // A non-"0" minute here is a discrete list, which has segments.
402
- return listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
530
+ // A non-"0" minute here is a discrete list, which has segments; an
531
+ // offset/uneven step enumerated to that list reads as a stride.
532
+ return strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour',
533
+ opts) ?? listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
403
534
  'minute', 'hour', opts);
404
535
  }
405
536
 
@@ -432,6 +563,16 @@ function hourWindow(window: {from: number; to: number; last: number},
432
563
  // a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
433
564
  function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
434
565
  opts: NormalizedOptions): string {
566
+ // An hour step (or arithmetic-progression hour list) under a single pinned
567
+ // minute reads as a cadence rather than a cross-product of clock times.
568
+ if (ir.shapes.minute === 'single') {
569
+ const cadence = hourCadence(ir, +ir.pattern.minute, opts);
570
+
571
+ if (cadence !== null) {
572
+ return cadence;
573
+ }
574
+ }
575
+
435
576
  const plain = mixedTwelve(plan.times);
436
577
  const times = plan.times.map(function clock(time) {
437
578
  return getTime({
@@ -451,6 +592,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
451
592
  function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
452
593
  opts: NormalizedOptions): string {
453
594
  if (plan.fold) {
595
+ // An hour step (or arithmetic-progression hour list) under the single
596
+ // pinned minute reads as a cadence, not a wall of clock times. (Returns
597
+ // null for an irregular list or a range, which keep folding below.)
598
+ const cadence = hourCadence(ir, +plan.minute, opts);
599
+
600
+ if (cadence !== null) {
601
+ return cadence;
602
+ }
603
+
454
604
  // A compact clock-time plan is reached only for discrete hours, which
455
605
  // have segments.
456
606
  const hasRange = ir.analyses.segments.hour!.some(function range(segment) {
@@ -470,9 +620,11 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
470
620
  }
471
621
 
472
622
  const phrase =
473
- // The non-fold branch is a minute list, which has segments.
474
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
475
- 'minute', 'hour', opts) +
623
+ // The non-fold branch is a minute list, which has segments. An
624
+ // offset/uneven step enumerated to that list reads as a stride.
625
+ (strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
626
+ listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
627
+ 'minute', 'hour', opts)) +
476
628
  ', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
477
629
  trailingQualifier(ir, opts);
478
630
 
@@ -544,6 +696,71 @@ const renderers = {
544
696
 
545
697
  // --- Step phrases. ---
546
698
 
699
+ // Speak a step cadence over a `cycle`-long field ("every N <unit>s [from M
700
+ // [through K]] past the <anchor>"). A clean stride from the top of the cycle
701
+ // is the bare cadence; a uniform offset (start within the first interval, the
702
+ // interval still tiling the cycle) names only its start, since it wraps cleanly
703
+ // and has no distinct endpoint; a non-uniform stride (start >= interval, or an
704
+ // interval that does not tile the cycle) pins both endpoints so the bounded,
705
+ // non-wrapping set reads unambiguously. This is the one phrasing for every
706
+ // step the renderer speaks, whether the core kept it a step shape (a clean
707
+ // cadence) or enumerated it to a fire list (an offset/uneven set the list
708
+ // path recognizes as an arithmetic progression).
709
+ function renderStride(stride: Stride, opts: NormalizedOptions): string {
710
+ const {interval, start, last, cycle, unit, anchor} = stride;
711
+ const cadence = 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
712
+ const tiles = cycle % interval === 0;
713
+
714
+ if (start === 0 && tiles) {
715
+ return cadence;
716
+ }
717
+
718
+ if (start < interval && tiles) {
719
+ // A clean wrap from a non-zero offset: name the start, no endpoint.
720
+ return cadence + ' from ' + getNumber(start, opts) + ' ' +
721
+ pluralize(start, unit) + ' past the ' + anchor;
722
+ }
723
+
724
+ // A bounded, non-wrapping set: pin both endpoints. The two bounds share one
725
+ // number style (all spelled, or all numerals once either crosses ten),
726
+ // matching the range idiom ("from 0 through 30").
727
+ const num = seriesNumber([start, last], opts);
728
+
729
+ return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
730
+ pluralize(last, unit) + ' past the ' + anchor;
731
+ }
732
+
733
+ // The sorted numeric values a field's segments cover, or null if any segment
734
+ // is not a discrete single (a range or sub-step is not a plain fire list).
735
+ function singleValues(segments: Segment[]): number[] | null {
736
+ const values: number[] = [];
737
+
738
+ for (const segment of segments) {
739
+ if (segment.kind !== 'single') {
740
+ return null;
741
+ }
742
+
743
+ values.push(+segment.value);
744
+ }
745
+
746
+ return values;
747
+ }
748
+
749
+ // Speak a minute/second field's enumerated fires as a step cadence when they
750
+ // form an arithmetic progression long enough to beat the list (the core
751
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
752
+ // the renderer recognizes the progression). Returns null for a non-progression
753
+ // or a too-short list, leaving the caller to enumerate.
754
+ function strideFromSegments(segments: Segment[], unit: string, anchor: string,
755
+ opts: NormalizedOptions): string | null {
756
+ const values = singleValues(segments);
757
+ const step = values && arithmeticStep(values);
758
+
759
+ return step ?
760
+ renderStride({...step, cycle: 60, unit, anchor}, opts) :
761
+ null;
762
+ }
763
+
547
764
  // Phrase a `start/interval` step segment for a field that cycles every 60
548
765
  // units (seconds and minutes). `unit` is the singular noun and `anchor` is
549
766
  // the larger unit the values are counted against. Interval-one steps never
@@ -559,23 +776,23 @@ function stepCycle60(segment: StepSegment, unit: string,
559
776
  }
560
777
 
561
778
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
562
- const interval = segment.interval;
563
779
 
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 …").
567
- if (segment.fires.length <= 3) {
568
- return listPastThe(numberWords(segment.fires, opts), unit, anchor,
569
- opts);
570
- }
571
-
572
- return 'every ' + getNumber(interval, opts) + ' ' + unit + 's from ' +
573
- getNumber(start, opts) + ' ' + pluralize(start, unit) +
574
- ' past the ' + anchor;
780
+ // A short offset cadence lists its fires; otherwise the stride phrasing
781
+ // names the interval and its offset ("every six minutes from five …"). A
782
+ // step shape only reaches here as a clean cadence (the interval tiles 60),
783
+ // so the stride collapses to the bare or uniform-offset form.
784
+ if (start !== 0 && segment.fires.length <= 3) {
785
+ return listPastThe(numberWords(segment.fires, opts), unit, anchor, opts);
575
786
  }
576
787
 
577
- // A clean stride from the top of the cycle is the bare cadence.
578
- return 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
788
+ return renderStride({
789
+ interval: segment.interval,
790
+ start,
791
+ last: segment.fires[segment.fires.length - 1],
792
+ cycle: 60,
793
+ unit,
794
+ anchor
795
+ }, opts);
579
796
  }
580
797
 
581
798
  // Phrase a `start/interval` step segment for the hour field (cycles every
@@ -605,6 +822,154 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
605
822
  getTime({hour: start, minute: 0}, opts);
606
823
  }
607
824
 
825
+ // Speak an hour stride as a cadence with clock-time bounds, the 24-cycle
826
+ // analog of renderStride: a clean stride from midnight is the bare cadence
827
+ // ("every two hours"); a clean offset names only its start ("every six hours
828
+ // from 2 a.m."); a bounded or non-tiling stride pins both clock-time endpoints
829
+ // ("every two hours from 9 a.m. through 5 p.m.") so the bounded set reads
830
+ // unambiguously. Used wherever an hour step (or arithmetic-progression hour
831
+ // list) would otherwise be cross-multiplied into a wall of clock times.
832
+ function hourStrideCadence(stride: {start: number; interval: number;
833
+ last: number}, opts: NormalizedOptions): string {
834
+ const {start, interval, last} = stride;
835
+ const cadence = 'every ' + getNumber(interval, opts) + ' hours';
836
+ const tiles = 24 % interval === 0;
837
+
838
+ if (start === 0 && tiles) {
839
+ return cadence;
840
+ }
841
+
842
+ if (start < interval && tiles) {
843
+ return cadence + ' from ' + getTime({hour: start, minute: 0}, opts);
844
+ }
845
+
846
+ return cadence + ' from ' + getTime({hour: start, minute: 0}, opts) +
847
+ through(opts) + getTime({hour: last, minute: 0}, opts);
848
+ }
849
+
850
+ // The hour field's stride, or null when the hour is not a cadence: a step
851
+ // segment yields its {start, interval, last} directly; an all-single hour
852
+ // list yields one only when its values form a long-enough arithmetic
853
+ // progression (so an irregular list like 9,17 keeps enumerating). The IR is
854
+ // unchanged — the renderer recognizes the stride and speaks it as a cadence
855
+ // instead of the clock-time cross-product.
856
+ function hourStride(ir: IR):
857
+ {start: number; interval: number; last: number} | null {
858
+ // Reached only from the clock-time paths, which run under discrete hours
859
+ // and so always carry hour segments.
860
+ const segments = ir.analyses.segments.hour!;
861
+
862
+ if (segments.length === 1 && segments[0].kind === 'step') {
863
+ const segment = segments[0];
864
+ const start = segment.startToken === '*' ?
865
+ 0 :
866
+ +segment.startToken.split('-')[0];
867
+
868
+ return {interval: segment.interval, last: segment.fires[
869
+ segment.fires.length - 1], start};
870
+ }
871
+
872
+ const values = singleValues(segments);
873
+ const step = values && arithmeticStep(values);
874
+
875
+ return step || null;
876
+ }
877
+
878
+ // The second's status against a pinned minute: a wildcard or sub-minute step
879
+ // fills the minute (a "for one minute" frame at minute 0); a single 0 is just
880
+ // the top of the minute (no clause); anything else needs its own clause.
881
+ function subMinuteSecond(ir: IR): boolean {
882
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
883
+ }
884
+
885
+ // The lead clause for an hour-cadence rendering: the second and the pinned
886
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
887
+ // list, or range second is counted "past the hour" (the minute-0 is the top
888
+ // of the hour), and a wildcard or sub-minute step second takes a "for one
889
+ // minute" frame (the whole minute-0 window). A non-zero minute is a real
890
+ // clock minute: the second leads with its own "past the minute" clause (if
891
+ // any), then the minute reads "M minutes past the hour".
892
+ function hourCadenceLead(ir: IR, minute: number,
893
+ opts: NormalizedOptions): string {
894
+ if (minute === 0) {
895
+ if (subMinuteSecond(ir)) {
896
+ return secondsClause(ir, 'minute', opts) + ' for one minute';
897
+ }
898
+
899
+ return secondsClause(ir, 'hour', opts);
900
+ }
901
+
902
+ const minutePhrase = getNumber(minute, opts) + ' ' +
903
+ pluralize(minute, 'minute') + ' past the hour';
904
+
905
+ // A single 0 second is just the top of the minute, so the minute leads
906
+ // alone; any other second prefixes its own clause.
907
+ if (ir.pattern.second === '0') {
908
+ return minutePhrase;
909
+ }
910
+
911
+ return secondsClause(ir, 'minute', opts) + ', ' + minutePhrase;
912
+ }
913
+
914
+ // Render an hour step (or arithmetic-progression hour list) under a single
915
+ // pinned minute and a second as a cadence — the lead clause, then the hour
916
+ // cadence — instead of cross-multiplying the hours into a wall of clock
917
+ // times. Returns null when the hour is not a stride (an irregular list, a
918
+ // single hour, or a range), or when the cross-product is short enough that
919
+ // enumeration is no longer than the cadence: a meaningful second (anything
920
+ // but a plain :00) makes every clock time three digit-groups, so any stride
921
+ // is worth compacting; otherwise the stride must exceed the clock-time cap,
922
+ // the same point at which the core itself stops enumerating. Renderer-only;
923
+ // the IR is unchanged.
924
+ function hourCadence(ir: IR, minute: number,
925
+ opts: NormalizedOptions): string | null {
926
+ const stride = hourStride(ir);
927
+
928
+ if (!stride) {
929
+ return null;
930
+ }
931
+
932
+ const fires = (stride.last - stride.start) / stride.interval + 1;
933
+
934
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
935
+ return null;
936
+ }
937
+
938
+ // A wildcard or sub-minute step second confined to minute 0 of a clean
939
+ // hour stride is a confinement, not a juxtaposed cadence: it reads "for one
940
+ // minute during every other hour", matching the "every minute during every
941
+ // other hour" idiom and keeping it distinct from the bare hour-step form
942
+ // ("every two hours") so the minute-0 confinement is never heard as it.
943
+ const confinement = minute === 0 && subMinuteSecond(ir) &&
944
+ cleanStrideSegment(ir);
945
+
946
+ if (confinement) {
947
+ return secondsClause(ir, 'minute', opts) + ' for one minute ' +
948
+ everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
949
+ }
950
+
951
+ return hourCadenceLead(ir, minute, opts) + ', ' +
952
+ hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
953
+ }
954
+
955
+ // The hour step segment when the hour is a clean stride with an idiomatic
956
+ // ordinal ("every other", "every sixth"), suitable for the "during every Nth
957
+ // hour" confinement frame; null otherwise (an uneven stride, a bounded step,
958
+ // or an arithmetic-progression list, which keep the bounded cadence form).
959
+ function cleanStrideSegment(ir: IR): StepSegment | null {
960
+ // Reached only after hourStride confirmed a stride, so hour segments exist.
961
+ const segments = ir.analyses.segments.hour!;
962
+ const segment = segments.length === 1 && segments[0];
963
+
964
+ if (!segment || segment.kind !== 'step' ||
965
+ segment.startToken.indexOf('-') !== -1 ||
966
+ !(segment.interval in stepOrdinals)) {
967
+ return null;
968
+ }
969
+
970
+ return segment;
971
+ }
972
+
608
973
  // --- List and segment phrasing. ---
609
974
 
610
975
  // Chicago number style for a series: if any value crosses the spell-out
@@ -1169,7 +1534,7 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1169
1534
  // "3.45pm" / "9am" / "midday" for UK (Guardian), or "15:45" / "15.45" in
1170
1535
  // 24-hour mode.
1171
1536
  function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1172
- const {hour, minute, plain} = time;
1537
+ const {hour, minute, plain, explicit} = time;
1173
1538
  // Seconds are only shown when a specific non-zero value is supplied.
1174
1539
  const second = typeof time.second === 'number' && time.second > 0 ?
1175
1540
  time.second :
@@ -1179,12 +1544,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1179
1544
  // Hour/minute arrive as numbers or raw field tokens (a range bound or
1180
1545
  // single value is a string); `clockDigits` types them as numbers but
1181
1546
  // `pad` stringifies either form to the same digits. Cast to keep the
1182
- // value byte-identical rather than coercing it.
1547
+ // value byte-identical rather than coercing it. The 24-hour form always
1548
+ // shows the minute, so it is already explicit.
1183
1549
  return clockDigits({hour: hour as number, minute: minute as number,
1184
1550
  second}, {pad: true, sep: opts.style.sep});
1185
1551
  }
1186
1552
 
1187
- return twelveHourTime({hour, minute, second, plain}, opts);
1553
+ return twelveHourTime({hour, minute, second, plain, explicit}, opts);
1188
1554
  }
1189
1555
 
1190
1556
  // The 12-hour form of a clock time: "9:30 a.m.", "9 a.m." on the hour, or
@@ -1193,13 +1559,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1193
1559
  // stays in one number style.
1194
1560
  function twelveHourTime(
1195
1561
  time: {hour: number | string; minute: number | string; second: number;
1196
- plain?: boolean},
1562
+ plain?: boolean; explicit?: boolean},
1197
1563
  opts: NormalizedOptions
1198
1564
  ): string {
1199
- const {hour, minute, second, plain} = time;
1565
+ const {hour, minute, second, plain, explicit} = time;
1200
1566
  const style = opts.style;
1201
1567
 
1202
- if (!plain && +minute === 0 && !second) {
1568
+ if (!plain && !explicit && +minute === 0 && !second) {
1203
1569
  if (+hour === 0) {
1204
1570
  return style.midnight;
1205
1571
  }
@@ -1211,9 +1577,11 @@ function twelveHourTime(
1211
1577
 
1212
1578
  // `hour`/`minute` may be raw field tokens; the arithmetic below coerces
1213
1579
  // them numerically, matching `clockDigits`. Cast for the modulo/compare.
1580
+ // `explicit` keeps the minute (":00") rather than leaning down to the bare
1581
+ // hour, so a pinned minute-0 stays visible.
1214
1582
  const digits = clockDigits(
1215
1583
  {hour: (hour as number) % 12 || 12, minute: minute as number, second},
1216
- {lean: true, sep: style.sep});
1584
+ {lean: !explicit, sep: style.sep});
1217
1585
 
1218
1586
  return digits + (style.closeUp ? '' : ' ') +
1219
1587
  ((hour as number) < 12 ? style.am : style.pm);
@@ -1255,11 +1623,10 @@ function getOrdinal(n: number | string): string {
1255
1623
  return n + suffix;
1256
1624
  }
1257
1625
 
1258
- // Get English month names from a number or from an abbreviation.
1626
+ // Get English month names from a canonical month number (months are never
1627
+ // Quartz, so the field is always number-canonicalized by the core).
1259
1628
  function getMonth(m: number | string, opts: NormalizedOptions): string {
1260
- // `m` is a month number (indexing `monthNames`) or an abbreviation token
1261
- // (indexing `monthAbbreviations`); the unmatched table yields undefined.
1262
- const month = monthNames[m as number] || monthAbbreviations[m];
1629
+ const month = monthNames[+m];
1263
1630
 
1264
1631
  // A valid month always resolves to a name pair, so the guarded lookup is
1265
1632
  // a string; the cast keeps the original null-guard expression intact.
@@ -1287,7 +1654,10 @@ const en: Language = {
1287
1654
  fallback: 'an unrecognizable cron pattern',
1288
1655
  options: normalizeOptions,
1289
1656
  reboot: 'at system startup',
1290
- sentence: (description) => 'Runs ' + description + '.'
1657
+ // A description ending in an abbreviation already carries its period
1658
+ // ("…9 a.m."), so closing the sentence must not double it.
1659
+ sentence: (description) =>
1660
+ 'Runs ' + description + (description.endsWith('.') ? '' : '.')
1291
1661
  };
1292
1662
 
1293
1663
  export default en;