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.
@@ -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,6 +21,18 @@ 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
38
  // string); `plain` suppresses the noon/midnight words. `explicit` forces the
@@ -174,11 +188,35 @@ function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
174
188
  trailingQualifier(ir, opts);
175
189
  }
176
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
+
177
205
  // A meaningful second under minute/hour shapes the earlier strategies
178
206
  // deferred on: the second leads with its own clause and the rest of the
179
207
  // pattern follows.
180
208
  function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
181
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
+
182
220
  // A wildcard or stepped second under a minute pinned to a single value
183
221
  // across one or more specific hours. The clock-time rest collapses the
184
222
  // pinned minute into the hour, and on the clock a pinned minute-0 reads as
@@ -255,6 +293,16 @@ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
255
293
  // e.g. "at 5 and 10 seconds past the minute" or "every second from zero
256
294
  // through 30 past the minute".
257
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 {
258
306
  const secondField = ir.pattern.second;
259
307
  const shape = ir.shapes.second;
260
308
 
@@ -266,7 +314,7 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
266
314
  // The plan reached this clause only for a stepped second field, whose
267
315
  // first segment is always a step segment.
268
316
  return stepCycle60(ir.analyses.segments.second![0] as StepSegment,
269
- 'second', 'minute', opts);
317
+ 'second', anchor, opts);
270
318
  }
271
319
 
272
320
  if (shape === 'range') {
@@ -274,17 +322,20 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
274
322
  const num = seriesNumber(bounds, opts);
275
323
 
276
324
  return 'every second from ' + num(bounds[0]) +
277
- through(opts) + num(bounds[1]) + ' past the minute';
325
+ through(opts) + num(bounds[1]) + ' past the ' + anchor;
278
326
  }
279
327
 
280
328
  if (shape === 'single') {
281
329
  return 'at ' + getNumber(secondField, opts) + ' ' +
282
- pluralize(secondField, 'second') + ' past the minute';
330
+ pluralize(secondField, 'second') + ' past the ' + anchor;
283
331
  }
284
332
 
285
- // A non-wildcard second under the list/step path always has segments.
286
- return listPastThe(segmentWords(ir.analyses.segments.second!, opts),
287
- '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);
288
339
  }
289
340
 
290
341
  // --- Minute renderers. ---
@@ -312,9 +363,13 @@ function renderRangeOfMinutes(ir: IR, plan: PlanOf<'rangeOfMinutes'>,
312
363
  function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
313
364
  opts: NormalizedOptions): string {
314
365
  // A multiple-minutes plan is selected only for a minute list, which has
315
- // segments.
316
- return listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
317
- '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);
318
373
  }
319
374
 
320
375
  // A repeating minute step, qualified by the active hour window(s).
@@ -376,9 +431,11 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
376
431
  const times = hourTimesFromPlan(ir, plan.times, true, opts);
377
432
  const lead = plan.form === 'range' ?
378
433
  minuteRangeLead(ir.pattern.minute, opts) :
379
- // The 'list' form is a minute list, which has segments.
380
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
381
- '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);
382
439
 
383
440
  return lead + ', at ' + times + trailingQualifier(ir, opts);
384
441
  }
@@ -414,8 +471,15 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
414
471
  trailingQualifier(ir, opts);
415
472
  }
416
473
 
417
- return minuteRangeLead(ir.pattern.minute, opts) + ', ' +
418
- 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);
419
483
  }
420
484
 
421
485
  // Lead phrase for a plain minute range: "every minute from <a> through <b>
@@ -463,8 +527,10 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
463
527
  return 'every hour';
464
528
  }
465
529
 
466
- // A non-"0" minute here is a discrete list, which has segments.
467
- 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),
468
534
  'minute', 'hour', opts);
469
535
  }
470
536
 
@@ -497,6 +563,16 @@ function hourWindow(window: {from: number; to: number; last: number},
497
563
  // a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
498
564
  function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
499
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
+
500
576
  const plain = mixedTwelve(plan.times);
501
577
  const times = plan.times.map(function clock(time) {
502
578
  return getTime({
@@ -516,6 +592,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
516
592
  function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
517
593
  opts: NormalizedOptions): string {
518
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
+
519
604
  // A compact clock-time plan is reached only for discrete hours, which
520
605
  // have segments.
521
606
  const hasRange = ir.analyses.segments.hour!.some(function range(segment) {
@@ -535,9 +620,11 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
535
620
  }
536
621
 
537
622
  const phrase =
538
- // The non-fold branch is a minute list, which has segments.
539
- listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
540
- '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)) +
541
628
  ', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
542
629
  trailingQualifier(ir, opts);
543
630
 
@@ -609,6 +696,71 @@ const renderers = {
609
696
 
610
697
  // --- Step phrases. ---
611
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
+
612
764
  // Phrase a `start/interval` step segment for a field that cycles every 60
613
765
  // units (seconds and minutes). `unit` is the singular noun and `anchor` is
614
766
  // the larger unit the values are counted against. Interval-one steps never
@@ -624,23 +776,23 @@ function stepCycle60(segment: StepSegment, unit: string,
624
776
  }
625
777
 
626
778
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
627
- const interval = segment.interval;
628
779
 
629
- if (start !== 0) {
630
- // A short offset cadence lists its fires; a longer one names the
631
- // interval and its starting offset ("every six minutes from five …").
632
- if (segment.fires.length <= 3) {
633
- return listPastThe(numberWords(segment.fires, opts), unit, anchor,
634
- opts);
635
- }
636
-
637
- return 'every ' + getNumber(interval, opts) + ' ' + unit + 's from ' +
638
- getNumber(start, opts) + ' ' + pluralize(start, unit) +
639
- ' 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);
640
786
  }
641
787
 
642
- // A clean stride from the top of the cycle is the bare cadence.
643
- 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);
644
796
  }
645
797
 
646
798
  // Phrase a `start/interval` step segment for the hour field (cycles every
@@ -670,6 +822,154 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
670
822
  getTime({hour: start, minute: 0}, opts);
671
823
  }
672
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
+
673
973
  // --- List and segment phrasing. ---
674
974
 
675
975
  // Chicago number style for a series: if any value crosses the spell-out