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.
@@ -10,8 +10,8 @@
10
10
  // case-pair construction wherever digits appear.
11
11
 
12
12
  import {clockDigits, numeral} from '../../core/format.js';
13
- import {weekdayNumbers} from '../../core/specs.js';
14
- import {toFieldNumber} from '../../core/util.js';
13
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
14
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
15
15
  import {resolveDialect} from './dialects.js';
16
16
  import type {
17
17
  ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -63,6 +63,7 @@ interface UnitForms {
63
63
  mark: string;
64
64
  anchor: string;
65
65
  ela: string;
66
+ ill: string;
66
67
  gen: string;
67
68
  }
68
69
 
@@ -170,12 +171,14 @@ const units: {minute: UnitForms; second: UnitForms} = {
170
171
  mark: 'joka tunti',
171
172
  anchor: 'jokaisen tunnin',
172
173
  ela: 'minuutista',
174
+ ill: 'minuuttiin',
173
175
  gen: 'minuutin'
174
176
  },
175
177
  second: {
176
178
  mark: 'joka minuutti',
177
179
  anchor: 'jokaisen minuutin',
178
180
  ela: 'sekunnista',
181
+ ill: 'sekuntiin',
179
182
  gen: 'sekunnin'
180
183
  }
181
184
  };
@@ -296,49 +299,85 @@ function renderSecondsWithinMinute(
296
299
  atMarks(minuteField, units.minute, true) + trailingQualifier(ir, opts);
297
300
  }
298
301
 
302
+ // A meaningful second composed over a minute-step cadence: the step leads and
303
+ // the second anchor follows after a comma, with the hour clause interleaved
304
+ // between them ("[step], [seconds][hour clause][trailing qualifier]"). The
305
+ // minute-frequency phrase is reconstructed directly here so the hour clause can
306
+ // sit between the step and the second anchor without duplicating the full
307
+ // renderMinuteFrequency logic; its hours-first reorder is intentionally NOT
308
+ // applied (the step-leads form is the correct shape for this construction).
309
+ function composeSecondsOverMinuteStep(
310
+ ir: IR,
311
+ freq: Extract<PlanNode, {kind: 'minuteFrequency'}>,
312
+ opts: NormalizedOptions
313
+ ): string {
314
+ const seg = stepSegment(ir.analyses.segments.minute!);
315
+ const stepPhrase = stepCycle60(seg, units.minute, opts);
316
+
317
+ if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
318
+ // The step renders as an anchored kohdalla list rather than a cadence, so
319
+ // the hours-first reorder applies here too: bare hours lead, minute anchors
320
+ // follow, then the seconds clause.
321
+ const bareHours = kloFromTimes(ir, freq.hours.times, opts);
322
+
323
+ return hoursFirstMinutes(bareHours, ir, opts) + ', ' +
324
+ secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
325
+ }
326
+
327
+ let hourClause = '';
328
+
329
+ if (freq.hours.kind === 'during') {
330
+ hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
331
+ }
332
+ else if (freq.hours.kind === 'window') {
333
+ hourClause = ' ' + hourWindow(freq.hours, opts);
334
+ }
335
+ else if (freq.hours.kind === 'step') {
336
+ hourClause = ' ' +
337
+ everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
338
+ }
339
+
340
+ return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
341
+ hourClause + trailingQualifier(ir, opts);
342
+ }
343
+
344
+ // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
345
+ // would cross-multiply an hour stride under a single pinned minute, or null
346
+ // when that does not apply (a non-clock rest, a multi-valued minute, or an
347
+ // hour that is not a stride).
348
+ function composeHourCadence(
349
+ ir: IR,
350
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
351
+ opts: NormalizedOptions
352
+ ): string | null {
353
+ const clockRest = plan.rest.kind === 'clockTimes' ||
354
+ plan.rest.kind === 'compactClockTimes';
355
+
356
+ return clockRest && ir.shapes.minute === 'single' ?
357
+ hourCadence(ir, +ir.pattern.minute, opts) :
358
+ null;
359
+ }
360
+
299
361
  function renderComposeSeconds(
300
362
  ir: IR,
301
363
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
302
364
  opts: NormalizedOptions
303
365
  ): string {
366
+ // An hour step (or arithmetic-progression hour list) under a single pinned
367
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
368
+ // then the hour cadence ("30 sekunnin kohdalla, kahden tunnin välein"). The
369
+ // clock-time rest would otherwise cross-multiply the hours.
370
+ const cadence = composeHourCadence(ir, plan, opts);
371
+
372
+ if (cadence !== null) {
373
+ return cadence;
374
+ }
375
+
304
376
  // When the rest is a minute-step cadence, the step leads and the second
305
377
  // anchor follows after a comma (the comma marks the granularity boundary
306
- // between the two levels, not a flat list). Build:
307
- // "[step phrase], [seconds][hour clause][trailing qualifier]".
308
- //
309
- // The minute-frequency phrase is reconstructed directly here so the hour
310
- // clause can be interleaved between the step and the second anchor without
311
- // duplicating the full renderMinuteFrequency logic. The hours-first reorder
312
- // that applies inside renderMinuteFrequency is intentionally NOT applied
313
- // here (the step-leads form is the correct shape for this construction).
378
+ // between the two levels, not a flat list).
314
379
  if (plan.rest.kind === 'minuteFrequency' && ir.pattern.second !== '*') {
315
- const freq = plan.rest as Extract<PlanNode, {kind: 'minuteFrequency'}>;
316
- const seg = stepSegment(ir.analyses.segments.minute!);
317
- const stepPhrase = stepCycle60(seg, units.minute, opts);
318
- let hourClause = '';
319
-
320
- if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
321
- // The step renders as an anchored kohdalla list rather than a cadence,
322
- // so the hours-first reorder applies here too: bare hours lead, minute
323
- // anchors follow, then the seconds clause.
324
- const bareHours = kloFromTimes(ir, freq.hours.times, opts);
325
-
326
- return hoursFirstMinutes(bareHours, ir) + ', ' +
327
- secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
328
- }
329
- else if (freq.hours.kind === 'during' && !minuteStepIsAnchored(seg)) {
330
- hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
331
- }
332
- else if (freq.hours.kind === 'window') {
333
- hourClause = ' ' + hourWindow(freq.hours, opts);
334
- }
335
- else if (freq.hours.kind === 'step') {
336
- hourClause = ' ' +
337
- everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
338
- }
339
-
340
- return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
341
- hourClause + trailingQualifier(ir, opts);
380
+ return composeSecondsOverMinuteStep(ir, plan.rest, opts);
342
381
  }
343
382
 
344
383
  // A sub-minute second with the minute pinned to 0 and a specific hour: the
@@ -352,9 +391,34 @@ function renderComposeSeconds(
352
391
  return composeMinuteZero(ir, plan.rest, opts);
353
392
  }
354
393
 
394
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
395
+ // cadences that read as contradictory ("joka sekunti, kahden minuutin
396
+ // välein"). Bind them as "every second of every other minute" ("joka sekunti
397
+ // joka toisena minuuttina"), mirroring English. Other strides, a restricted
398
+ // hour, and an hour cadence keep the juxtaposed form.
399
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
400
+ return secondsLeadClause(ir, opts) + ' joka toisena minuuttina';
401
+ }
402
+
355
403
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
356
404
  }
357
405
 
406
+ // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
407
+ // cadences read as contradictory side by side, so they bind into one.
408
+ function isEveryOtherMinuteSeconds(
409
+ ir: IR,
410
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
411
+ ): boolean {
412
+ if (plan.rest.kind !== 'minuteFrequency' || ir.pattern.second !== '*' ||
413
+ ir.shapes.hour !== 'wildcard') {
414
+ return false;
415
+ }
416
+
417
+ const seg = stepSegment(ir.analyses.segments.minute!);
418
+
419
+ return seg.startToken === '*' && seg.interval === 2;
420
+ }
421
+
358
422
  // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
359
423
  // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
360
424
  // a range — a range would round-trip back to the whole hour) and trail the day
@@ -400,8 +464,11 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
400
464
  return atMarks(secondField, units.second, marked);
401
465
  }
402
466
 
403
- return atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
404
- units.second, marked);
467
+ // An offset/uneven step the core enumerated to this list reads as a stride
468
+ // cadence when the fires form a long-enough progression.
469
+ return strideFromSegments(ir.analyses.segments.second!, units.second, opts) ??
470
+ atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
471
+ units.second, marked);
405
472
  }
406
473
 
407
474
  // --- Minute renderers. ---
@@ -428,7 +495,7 @@ function renderRangeOfMinutes(
428
495
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
429
496
  opts: NormalizedOptions
430
497
  ): string {
431
- return minutesList(ir) + trailingQualifier(ir, opts);
498
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
432
499
  }
433
500
 
434
501
  function renderMultipleMinutes(
@@ -436,21 +503,27 @@ function renderMultipleMinutes(
436
503
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
437
504
  opts: NormalizedOptions
438
505
  ): string {
439
- return minutesList(ir) + trailingQualifier(ir, opts);
506
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
440
507
  }
441
508
 
442
- // "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range).
443
- function minutesList(ir: IR): string {
444
- return atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
445
- units.minute, true);
509
+ // "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range). An offset/
510
+ // uneven step the core enumerated to this list reads as a stride cadence when
511
+ // the fires form a long-enough progression ("kahden minuutin välein
512
+ // minuutista 3 minuuttiin 59").
513
+ function minutesList(ir: IR, opts: NormalizedOptions): string {
514
+ return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
515
+ atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
516
+ units.minute, true);
446
517
  }
447
518
 
448
519
  // The bare minute mark, for clauses where a specific hour follows and
449
520
  // the "joka tunti" frequency would be redundant: "0–30 minuutin
450
- // kohdalla".
451
- function bareMinutes(ir: IR): string {
452
- return atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
453
- units.minute, false);
521
+ // kohdalla". A progression reads as its bounded cadence (which carries no
522
+ // per-hour frequency to drop).
523
+ function bareMinutes(ir: IR, opts: NormalizedOptions): string {
524
+ return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
525
+ atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
526
+ units.minute, false);
454
527
  }
455
528
 
456
529
  // Whether a minute step renders as an anchored "kohdalla" clause rather
@@ -500,7 +573,21 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
500
573
  // (plural genitive "minuuttien"; replaces the leading "joka tunti"). Used
501
574
  // when a range or multi-point minute list over enumerated hours renders
502
575
  // hours-first.
503
- function hoursFirstMinutes(hoursStr: string, ir: IR): string {
576
+ function hoursFirstMinutes(
577
+ hoursStr: string,
578
+ ir: IR,
579
+ opts: NormalizedOptions
580
+ ): string {
581
+ // An offset/uneven step the core enumerated to this list reads as a stride
582
+ // cadence ("aina kahden minuutin välein minuutista 3 minuuttiin 59") when
583
+ // the fires form a long-enough progression, rather than the kohdalla list.
584
+ const stride =
585
+ strideFromSegments(ir.analyses.segments.minute!, units.minute, opts);
586
+
587
+ if (stride) {
588
+ return hoursStr + ' aina ' + stride;
589
+ }
590
+
504
591
  return hoursStr + ' aina minuuttien ' +
505
592
  joinList(segmentWords(ir.analyses.segments.minute!)) + ' kohdalla';
506
593
  }
@@ -546,7 +633,8 @@ function renderMinuteFrequency(
546
633
  if (minuteStepIsAnchored(seg)) {
547
634
  const bareHours = kloFromTimes(ir, plan.hours.times, opts);
548
635
 
549
- return hoursFirstMinutes(bareHours, ir) + trailingQualifier(ir, opts);
636
+ return hoursFirstMinutes(bareHours, ir, opts) +
637
+ trailingQualifier(ir, opts);
550
638
  }
551
639
 
552
640
  return stepCycle60(seg, units.minute, opts) + ' ' +
@@ -606,7 +694,7 @@ function renderMinutesAcrossHours(
606
694
 
607
695
  // Range+isolated hours: minute-first, bare minutes, sekä klo.
608
696
  if (hoursAreRangeIsolated(ir.analyses.segments.hour!)) {
609
- return bareMinutes(ir) + ' ' +
697
+ return bareMinutes(ir, opts) + ' ' +
610
698
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
611
699
  trailingQualifier(ir, opts);
612
700
  }
@@ -616,7 +704,7 @@ function renderMinutesAcrossHours(
616
704
  // shows it).
617
705
  const hoursStr = kloFromTimes(ir, plan.times, opts);
618
706
 
619
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
707
+ return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
620
708
  }
621
709
 
622
710
  function renderMinuteSpanAcrossHourStep(
@@ -644,10 +732,10 @@ function renderMinuteSpanAcrossHourStep(
644
732
  if (segment.startToken.indexOf('-') !== -1) {
645
733
  const hoursStr = kloList(segment.fires, opts);
646
734
 
647
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
735
+ return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
648
736
  }
649
737
 
650
- return bareMinutes(ir) + hourStepTail(segment, opts) +
738
+ return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
651
739
  trailingQualifier(ir, opts);
652
740
  }
653
741
 
@@ -725,7 +813,7 @@ function renderHourRange(
725
813
  // A minute range over a single hour range renders hours-first
726
814
  // ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
727
815
  if (plan.minuteForm === 'range') {
728
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
816
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
729
817
  }
730
818
 
731
819
  // On the hour the window joins directly ("joka tunti klo 9–17"); a
@@ -744,7 +832,7 @@ function renderHourRange(
744
832
 
745
833
  // A minute list (≥2 values) over a single hour range renders hours-first
746
834
  // ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
747
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
835
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
748
836
  }
749
837
 
750
838
  function renderHourStep(
@@ -779,6 +867,16 @@ function renderClockTimes(
779
867
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
780
868
  opts: NormalizedOptions
781
869
  ): string {
870
+ // An hour step (or arithmetic-progression hour list) under a single pinned
871
+ // minute reads as a cadence rather than a cross-product of clock times.
872
+ if (ir.shapes.minute === 'single') {
873
+ const cadence = hourCadence(ir, +ir.pattern.minute, opts);
874
+
875
+ if (cadence !== null) {
876
+ return cadence;
877
+ }
878
+ }
879
+
782
880
  if (plan.times.length === 1) {
783
881
  const time = plan.times[0];
784
882
 
@@ -802,6 +900,17 @@ function renderCompactClockTimes(
802
900
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
803
901
  opts: NormalizedOptions
804
902
  ): string {
903
+ // An hour step (or arithmetic-progression hour list) under the single pinned
904
+ // minute reads as a cadence, not a wall of clock times. (Returns null for an
905
+ // irregular list or a range, which keep folding below.)
906
+ if (plan.fold) {
907
+ const cadence = hourCadence(ir, plan.minute, opts);
908
+
909
+ if (cadence !== null) {
910
+ return cadence;
911
+ }
912
+ }
913
+
805
914
  const hourSegs = ir.analyses.segments.hour!;
806
915
 
807
916
  // Range+isolated hours: join the isolated hour with "sekä klo" to stop it
@@ -815,7 +924,7 @@ function renderCompactClockTimes(
815
924
  ir.analyses.clockSecond, opts);
816
925
  }
817
926
 
818
- const phrase = bareMinutes(ir) + ' ' +
927
+ const phrase = bareMinutes(ir, opts) + ' ' +
819
928
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
820
929
  trailingQualifier(ir, opts);
821
930
 
@@ -832,7 +941,7 @@ function renderCompactClockTimes(
832
941
  // A minute list over purely enumerated hours (step fires, all singles) —
833
942
  // hours-first, drop "joka tunti".
834
943
  const hoursStr = hourSegmentTimes(ir, 0, null, opts);
835
- const phrase = hoursFirstMinutes(hoursStr, ir) +
944
+ const phrase = hoursFirstMinutes(hoursStr, ir, opts) +
836
945
  trailingQualifier(ir, opts);
837
946
 
838
947
  return ir.analyses.clockSecond ?
@@ -864,8 +973,82 @@ const renderers = {
864
973
 
865
974
  // --- Step phrases. ---
866
975
 
976
+ // A step cadence to phrase over a `cycle`-long field (60 for minute/second),
977
+ // running from `start` to `last`.
978
+ interface Stride {
979
+ interval: number;
980
+ start: number;
981
+ last: number;
982
+ cycle: number;
983
+ unit: UnitForms;
984
+ }
985
+
986
+ // Speak a step cadence over a `cycle`-long field. A clean stride from the top
987
+ // of the cycle is the bare cadence ("viiden minuutin välein"); a uniform
988
+ // offset (start within the first interval, the interval still dividing the
989
+ // cycle) names only its start, since it wraps cleanly with no distinct
990
+ // endpoint ("kuuden minuutin välein jokaisen tunnin minuutista 5 alkaen"); a
991
+ // non-uniform stride (start >= interval, or an interval that does not divide
992
+ // the cycle) pins both endpoints so the bounded, non-wrapping set reads
993
+ // unambiguously ("kahden minuutin välein minuutista 3 minuuttiin 59"). This is
994
+ // the one phrasing for every step the renderer speaks, whether the core kept
995
+ // it a step shape (a clean cadence) or enumerated it to a fire list (an
996
+ // offset/uneven set the list path recognizes as a progression).
997
+ function renderStride(stride: Stride, opts: NormalizedOptions): string {
998
+ const {interval, start, last, cycle, unit} = stride;
999
+ const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
1000
+ const tiles = cycle % interval === 0;
1001
+
1002
+ if (start === 0 && tiles) {
1003
+ return cadence;
1004
+ }
1005
+
1006
+ if (start < interval && tiles) {
1007
+ return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
1008
+ ' alkaen';
1009
+ }
1010
+
1011
+ return cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last;
1012
+ }
1013
+
1014
+ // Speak a minute/second field's enumerated fires as a step cadence when they
1015
+ // form an arithmetic progression long enough to beat the list (the core
1016
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1017
+ // the renderer recognizes the progression). Returns null for a non-progression
1018
+ // or a too-short list, leaving the caller to enumerate.
1019
+ function strideFromSegments(
1020
+ segments: Segment[],
1021
+ unit: UnitForms,
1022
+ opts: NormalizedOptions
1023
+ ): string | null {
1024
+ const values = singleValues(segments);
1025
+ const step = values && arithmeticStep(values);
1026
+
1027
+ return step ?
1028
+ renderStride({...step, cycle: 60, unit}, opts) :
1029
+ null;
1030
+ }
1031
+
1032
+ // The sorted numeric values a field's segments cover, or null if any segment
1033
+ // is not a discrete single (a range or sub-step is not a plain fire list).
1034
+ function singleValues(segments: Segment[]): number[] | null {
1035
+ const values: number[] = [];
1036
+
1037
+ for (const segment of segments) {
1038
+ if (segment.kind !== 'single') {
1039
+ return null;
1040
+ }
1041
+
1042
+ values.push(+segment.value);
1043
+ }
1044
+
1045
+ return values;
1046
+ }
1047
+
867
1048
  // "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
868
- // "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen".
1049
+ // "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen". A step shape
1050
+ // only reaches here as a clean or uniform-offset cadence; an offset/uneven set
1051
+ // arrives as a fire list and is recognized by the list path instead.
869
1052
  function stepCycle60(
870
1053
  segment: StepSegment,
871
1054
  unit: UnitForms,
@@ -876,21 +1059,20 @@ function stepCycle60(
876
1059
  }
877
1060
 
878
1061
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
879
- const interval = segment.interval;
880
- const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
881
1062
 
882
- if (start !== 0) {
883
- if (segment.fires.length <= 3) {
884
- return atMarks(joinList(wordList(segment.fires)), unit, true);
885
- }
886
-
887
- return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
888
- ' alkaen';
1063
+ // A short offset cadence still lists its fires; the stride phrasing names
1064
+ // the interval and offset only once there are enough fires to beat the list.
1065
+ if (start !== 0 && segment.fires.length <= 3) {
1066
+ return atMarks(joinList(wordList(segment.fires)), unit, true);
889
1067
  }
890
1068
 
891
- // A clean stride from the top of the cycle is the bare cadence. (An uneven
892
- // stride is rewritten to its fires upstream and never reaches here.)
893
- return cadence;
1069
+ return renderStride({
1070
+ interval: segment.interval,
1071
+ start,
1072
+ last: segment.fires[segment.fires.length - 1],
1073
+ cycle: 60,
1074
+ unit
1075
+ }, opts);
894
1076
  }
895
1077
 
896
1078
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
@@ -917,6 +1099,156 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
917
1099
  return cadence + ' klo ' + hourElatives[start] + ' alkaen';
918
1100
  }
919
1101
 
1102
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
1103
+
1104
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
1105
+ // from midnight is the bare cadence ("kahden tunnin välein"); a clean offset
1106
+ // names only its start ("kuuden tunnin välein klo 2:sta alkaen"); a bounded or
1107
+ // non-tiling stride pins both clock-time endpoints ("kahden tunnin välein klo
1108
+ // 9–17") so the bounded set reads unambiguously. Used wherever an hour step
1109
+ // (or arithmetic-progression hour list) would otherwise be cross-multiplied
1110
+ // into a wall of clock times.
1111
+ function hourStrideCadence(
1112
+ stride: {start: number; interval: number; last: number},
1113
+ opts: NormalizedOptions
1114
+ ): string {
1115
+ const {start, interval, last} = stride;
1116
+ const cadence = genitive(interval, opts) + ' tunnin välein';
1117
+ const tiles = 24 % interval === 0;
1118
+
1119
+ if (start === 0 && tiles) {
1120
+ return cadence;
1121
+ }
1122
+
1123
+ if (start < interval && tiles) {
1124
+ return cadence + ' klo ' + hourElatives[start] + ' alkaen';
1125
+ }
1126
+
1127
+ return cadence + ' ' +
1128
+ kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
1129
+ }
1130
+
1131
+ // The hour field's stride, or null when the hour is not a cadence: a step
1132
+ // segment yields its {start, interval, last} directly; an all-single hour list
1133
+ // yields one only when its values form a long-enough arithmetic progression
1134
+ // (so an irregular list like 9,17 keeps enumerating). The IR is unchanged —
1135
+ // the renderer recognizes the stride and speaks it as a cadence instead of the
1136
+ // clock-time cross-product.
1137
+ function hourStride(
1138
+ ir: IR
1139
+ ): {start: number; interval: number; last: number} | null {
1140
+ const segments = ir.analyses.segments.hour;
1141
+
1142
+ // A wildcard hour carries no segments (no discrete hours to stride over).
1143
+ if (!segments) {
1144
+ return null;
1145
+ }
1146
+
1147
+ if (segments.length === 1 && segments[0].kind === 'step') {
1148
+ const segment = segments[0];
1149
+ const start = segment.startToken === '*' ?
1150
+ 0 :
1151
+ +segment.startToken.split('-')[0];
1152
+
1153
+ return {interval: segment.interval, last: segment.fires[
1154
+ segment.fires.length - 1], start};
1155
+ }
1156
+
1157
+ const values = singleValues(segments);
1158
+ const step = values && arithmeticStep(values);
1159
+
1160
+ return step || null;
1161
+ }
1162
+
1163
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1164
+ // fills the minute (a "minuutin ajan" frame at minute 0); a single 0 is just
1165
+ // the top of the minute (no clause); anything else needs its own clause.
1166
+ function subMinuteSecond(ir: IR): boolean {
1167
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
1168
+ }
1169
+
1170
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1171
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
1172
+ // list, range, or step second is counted at its own bare "kohdalla" mark (the
1173
+ // minute-0 is the top of the hour), and a wildcard second takes a "minuutin
1174
+ // ajan" frame (the whole minute-0 window). A non-zero minute is a real clock
1175
+ // minute: the second leads with its own clause (if any), then the minute reads
1176
+ // at its bare "kohdalla" mark.
1177
+ function hourCadenceLead(ir: IR, minute: number,
1178
+ opts: NormalizedOptions): string {
1179
+ if (minute === 0) {
1180
+ if (subMinuteSecond(ir)) {
1181
+ return secondsLeadClause(ir, opts) + ' minuutin ajan';
1182
+ }
1183
+
1184
+ return secondsLeadClause(ir, opts);
1185
+ }
1186
+
1187
+ const minutePhrase = atMarks(String(minute), units.minute, false);
1188
+
1189
+ // A single 0 second is just the top of the minute, so the minute leads
1190
+ // alone; any other second prefixes its own clause.
1191
+ if (ir.pattern.second === '0') {
1192
+ return minutePhrase;
1193
+ }
1194
+
1195
+ return secondsLeadClause(ir, opts) + ', ' + minutePhrase;
1196
+ }
1197
+
1198
+ // Render an hour step (or arithmetic-progression hour list) under a single
1199
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1200
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1201
+ // Returns null when the hour is not a stride (an irregular list, a single
1202
+ // hour, or a range), or when the cross-product is short enough that
1203
+ // enumeration is no longer than the cadence: a meaningful second makes every
1204
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
1205
+ // the stride must exceed the clock-time cap, the same point at which the core
1206
+ // itself stops enumerating. Renderer-only; the IR is unchanged.
1207
+ function hourCadence(ir: IR, minute: number,
1208
+ opts: NormalizedOptions): string | null {
1209
+ const stride = hourStride(ir);
1210
+
1211
+ if (!stride) {
1212
+ return null;
1213
+ }
1214
+
1215
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1216
+
1217
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1218
+ return null;
1219
+ }
1220
+
1221
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1222
+ // stride is a confinement, not a juxtaposed cadence: it reads "minuutin ajan
1223
+ // joka toisen tunnin aikana", reusing the every-Nth-hour idiom so the
1224
+ // minute-0 window is never heard as the bare hour cadence.
1225
+ const segment = ir.analyses.segments.hour![0];
1226
+ const confined = minute === 0 && subMinuteSecond(ir) &&
1227
+ ir.analyses.segments.hour!.length === 1 && segment.kind === 'step' &&
1228
+ cleanHourStride(segment);
1229
+
1230
+ if (confined) {
1231
+ return secondsLeadClause(ir, opts) + ' minuutin ajan ' +
1232
+ everyNthHour(segment, opts) + trailingQualifier(ir, opts);
1233
+ }
1234
+
1235
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1236
+ hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1237
+ }
1238
+
1239
+ // Whether an hour step is a clean stride over the whole day — unbounded,
1240
+ // dividing 24, and starting within the first interval — so it confines to "joka
1241
+ // N:nnen tunnin aikana" rather than enumerating its fires.
1242
+ function cleanHourStride(segment: StepSegment): boolean {
1243
+ if (segment.startToken.indexOf('-') !== -1) {
1244
+ return false;
1245
+ }
1246
+
1247
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1248
+
1249
+ return 24 % segment.interval === 0 && start < segment.interval;
1250
+ }
1251
+
920
1252
  // --- Hour-time phrasing. ---
921
1253
 
922
1254
  // On-the-hour fires as one klo phrase: "klo 0, 10 ja 20".