cronli5 0.1.4 → 0.1.6

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,89 @@ 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
+ if (!clockRest || ir.shapes.minute !== 'single') {
357
+ return null;
358
+ }
359
+
360
+ const minute = +ir.pattern.minute;
361
+
362
+ return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
363
+ }
364
+
299
365
  function renderComposeSeconds(
300
366
  ir: IR,
301
367
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
302
368
  opts: NormalizedOptions
303
369
  ): string {
370
+ // An hour step (or arithmetic-progression hour list) under a single pinned
371
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
372
+ // then the hour cadence ("30 sekunnin kohdalla, kahden tunnin välein"). The
373
+ // clock-time rest would otherwise cross-multiply the hours.
374
+ const cadence = composeHourCadence(ir, plan, opts);
375
+
376
+ if (cadence !== null) {
377
+ return cadence;
378
+ }
379
+
304
380
  // When the rest is a minute-step cadence, the step leads and the second
305
381
  // 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).
382
+ // between the two levels, not a flat list).
314
383
  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);
384
+ return composeSecondsOverMinuteStep(ir, plan.rest, opts);
342
385
  }
343
386
 
344
387
  // A sub-minute second with the minute pinned to 0 and a specific hour: the
@@ -352,7 +395,40 @@ function renderComposeSeconds(
352
395
  return composeMinuteZero(ir, plan.rest, opts);
353
396
  }
354
397
 
355
- return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
398
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
399
+ // cadences that read as contradictory ("joka sekunti, kahden minuutin
400
+ // välein"). Bind them as "every second of every other minute" ("joka sekunti
401
+ // joka toisena minuuttina"), mirroring English. Other strides, a restricted
402
+ // hour, and an hour cadence keep the juxtaposed form.
403
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
404
+ return secondsLeadClause(ir, opts) + ' joka toisena minuuttina';
405
+ }
406
+
407
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
408
+ // leading clause, so the composer must not prepend a second lead that would
409
+ // double it. A wildcard or stepped second is not folded there (no
410
+ // clockSecond), so it still leads its own clause here.
411
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
412
+ ir.analyses.clockSecond;
413
+ const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
414
+
415
+ return lead + render(ir, plan.rest, opts);
416
+ }
417
+
418
+ // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
419
+ // cadences read as contradictory side by side, so they bind into one.
420
+ function isEveryOtherMinuteSeconds(
421
+ ir: IR,
422
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
423
+ ): boolean {
424
+ if (plan.rest.kind !== 'minuteFrequency' || ir.pattern.second !== '*' ||
425
+ ir.shapes.hour !== 'wildcard') {
426
+ return false;
427
+ }
428
+
429
+ const seg = stepSegment(ir.analyses.segments.minute!);
430
+
431
+ return seg.startToken === '*' && seg.interval === 2;
356
432
  }
357
433
 
358
434
  // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
@@ -400,8 +476,11 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
400
476
  return atMarks(secondField, units.second, marked);
401
477
  }
402
478
 
403
- return atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
404
- units.second, marked);
479
+ // An offset/uneven step the core enumerated to this list reads as a stride
480
+ // cadence when the fires form a long-enough progression.
481
+ return strideFromSegments(ir.analyses.segments.second!, units.second, opts) ??
482
+ atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
483
+ units.second, marked);
405
484
  }
406
485
 
407
486
  // --- Minute renderers. ---
@@ -428,7 +507,7 @@ function renderRangeOfMinutes(
428
507
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
429
508
  opts: NormalizedOptions
430
509
  ): string {
431
- return minutesList(ir) + trailingQualifier(ir, opts);
510
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
432
511
  }
433
512
 
434
513
  function renderMultipleMinutes(
@@ -436,21 +515,27 @@ function renderMultipleMinutes(
436
515
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
437
516
  opts: NormalizedOptions
438
517
  ): string {
439
- return minutesList(ir) + trailingQualifier(ir, opts);
518
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
440
519
  }
441
520
 
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);
521
+ // "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range). An offset/
522
+ // uneven step the core enumerated to this list reads as a stride cadence when
523
+ // the fires form a long-enough progression ("kahden minuutin välein
524
+ // minuutista 3 minuuttiin 59").
525
+ function minutesList(ir: IR, opts: NormalizedOptions): string {
526
+ return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
527
+ atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
528
+ units.minute, true);
446
529
  }
447
530
 
448
531
  // The bare minute mark, for clauses where a specific hour follows and
449
532
  // 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);
533
+ // kohdalla". A progression reads as its bounded cadence (which carries no
534
+ // per-hour frequency to drop).
535
+ function bareMinutes(ir: IR, opts: NormalizedOptions): string {
536
+ return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
537
+ atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
538
+ units.minute, false);
454
539
  }
455
540
 
456
541
  // Whether a minute step renders as an anchored "kohdalla" clause rather
@@ -500,7 +585,21 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
500
585
  // (plural genitive "minuuttien"; replaces the leading "joka tunti"). Used
501
586
  // when a range or multi-point minute list over enumerated hours renders
502
587
  // hours-first.
503
- function hoursFirstMinutes(hoursStr: string, ir: IR): string {
588
+ function hoursFirstMinutes(
589
+ hoursStr: string,
590
+ ir: IR,
591
+ opts: NormalizedOptions
592
+ ): string {
593
+ // An offset/uneven step the core enumerated to this list reads as a stride
594
+ // cadence ("aina kahden minuutin välein minuutista 3 minuuttiin 59") when
595
+ // the fires form a long-enough progression, rather than the kohdalla list.
596
+ const stride =
597
+ strideFromSegments(ir.analyses.segments.minute!, units.minute, opts);
598
+
599
+ if (stride) {
600
+ return hoursStr + ' aina ' + stride;
601
+ }
602
+
504
603
  return hoursStr + ' aina minuuttien ' +
505
604
  joinList(segmentWords(ir.analyses.segments.minute!)) + ' kohdalla';
506
605
  }
@@ -540,13 +639,24 @@ function renderMinuteFrequency(
540
639
  const seg = stepSegment(ir.analyses.segments.minute!);
541
640
 
542
641
  if (plan.hours.kind === 'during') {
642
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence
643
+ // after the minute step ("15 minuutin välein, viiden tunnin välein klo
644
+ // 0–20").
645
+ const cadence = unevenHourCadence(ir, opts);
646
+
647
+ if (cadence !== null) {
648
+ return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
649
+ trailingQualifier(ir, opts);
650
+ }
651
+
543
652
  // When the step renders as anchored ("kohdalla"), the per-hour windows
544
653
  // are redundant — use bare clock hours instead, then reorder to
545
654
  // hours-first: "klo <hours> aina minuuttien <spec> kohdalla".
546
655
  if (minuteStepIsAnchored(seg)) {
547
656
  const bareHours = kloFromTimes(ir, plan.hours.times, opts);
548
657
 
549
- return hoursFirstMinutes(bareHours, ir) + trailingQualifier(ir, opts);
658
+ return hoursFirstMinutes(bareHours, ir, opts) +
659
+ trailingQualifier(ir, opts);
550
660
  }
551
661
 
552
662
  return stepCycle60(seg, units.minute, opts) + ' ' +
@@ -599,14 +709,25 @@ function renderMinutesAcrossHours(
599
709
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
600
710
  opts: NormalizedOptions
601
711
  ): string {
712
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
713
+ // the minute clause ("joka minuutti, viiden tunnin välein klo 0–20"), not a
714
+ // wall of hour windows.
715
+ const cadence = unevenHourCadence(ir, opts);
716
+
602
717
  if (plan.form === 'wildcard') {
603
- return 'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
604
- trailingQualifier(ir, opts);
718
+ return cadence ?
719
+ 'joka minuutti, ' + cadence + trailingQualifier(ir, opts) :
720
+ 'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
721
+ trailingQualifier(ir, opts);
722
+ }
723
+
724
+ if (cadence !== null) {
725
+ return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
605
726
  }
606
727
 
607
728
  // Range+isolated hours: minute-first, bare minutes, sekä klo.
608
729
  if (hoursAreRangeIsolated(ir.analyses.segments.hour!)) {
609
- return bareMinutes(ir) + ' ' +
730
+ return bareMinutes(ir, opts) + ' ' +
610
731
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
611
732
  trailingQualifier(ir, opts);
612
733
  }
@@ -616,7 +737,7 @@ function renderMinutesAcrossHours(
616
737
  // shows it).
617
738
  const hoursStr = kloFromTimes(ir, plan.times, opts);
618
739
 
619
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
740
+ return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
620
741
  }
621
742
 
622
743
  function renderMinuteSpanAcrossHourStep(
@@ -626,28 +747,28 @@ function renderMinuteSpanAcrossHourStep(
626
747
  ): string {
627
748
  // An hour-step plan's first hour segment is always a step segment.
628
749
  const segment = stepSegment(ir.analyses.segments.hour!);
750
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
751
+ // offset-clean stride keeps its confinement / per-step phrasing.
752
+ const cadence = unevenHourCadence(ir, opts);
629
753
 
630
754
  // A wildcard span always sets the step off with a comma ("joka
631
755
  // minuutti, joka toinen tunti"); a restricted span joins a plain step
632
756
  // directly ("minuuteilla 0–30 joka toinen tunti").
633
- // A wildcard minute (a cadence) is reached only for a clean step and is
634
- // confined to every Nth hour; a restricted span is a per-hour window whose
635
- // recurrence joins as a plain step.
757
+ // A wildcard minute (a cadence) is reached only for a clean step (a bounded
758
+ // or uneven step routes through minutesAcrossHours instead) and is confined
759
+ // to every Nth hour; a restricted span is a per-hour window + plain step.
636
760
  if (plan.form === 'wildcard') {
637
761
  return 'joka minuutti ' + everyNthHour(segment, opts) +
638
762
  trailingQualifier(ir, opts);
639
763
  }
640
764
 
641
- // A bounded range-step (e.g. 9-17/2) whose fires enumerate as a klo-digit
642
- // list renders hours-first. A clean or offset unbounded step (e.g. 1/6,
643
- // */2) keeps minute-first with its step phrase.
644
- if (segment.startToken.indexOf('-') !== -1) {
645
- const hoursStr = kloList(segment.fires, opts);
646
-
647
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
765
+ // A bounded or uneven stride reads as its bounded cadence after the bare
766
+ // minutes ("minuuteilla 0–30, kahden tunnin välein klo 9–17").
767
+ if (cadence !== null) {
768
+ return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
648
769
  }
649
770
 
650
- return bareMinutes(ir) + hourStepTail(segment, opts) +
771
+ return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
651
772
  trailingQualifier(ir, opts);
652
773
  }
653
774
 
@@ -725,7 +846,7 @@ function renderHourRange(
725
846
  // A minute range over a single hour range renders hours-first
726
847
  // ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
727
848
  if (plan.minuteForm === 'range') {
728
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
849
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
729
850
  }
730
851
 
731
852
  // On the hour the window joins directly ("joka tunti klo 9–17"); a
@@ -744,7 +865,7 @@ function renderHourRange(
744
865
 
745
866
  // A minute list (≥2 values) over a single hour range renders hours-first
746
867
  // ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
747
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
868
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
748
869
  }
749
870
 
750
871
  function renderHourStep(
@@ -752,6 +873,15 @@ function renderHourStep(
752
873
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
753
874
  opts: NormalizedOptions
754
875
  ): string {
876
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence
877
+ // ("kahden tunnin välein klo 9–17"); an offset-clean step keeps its bare or
878
+ // "alkaen" cadence.
879
+ const cadence = unevenHourCadence(ir, opts);
880
+
881
+ if (cadence !== null) {
882
+ return cadence + trailingQualifier(ir, opts);
883
+ }
884
+
755
885
  return stepHours(stepSegment(ir.analyses.segments.hour!), opts) +
756
886
  trailingQualifier(ir, opts);
757
887
  }
@@ -779,6 +909,19 @@ function renderClockTimes(
779
909
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
780
910
  opts: NormalizedOptions
781
911
  ): string {
912
+ // An hour step or range (or arithmetic-progression hour list) under a single
913
+ // pinned minute reads as a cadence or window rather than a cross-product of
914
+ // clock times.
915
+ if (ir.shapes.minute === 'single') {
916
+ const minute = +ir.pattern.minute;
917
+ const cadence = hourCadence(ir, minute, opts) ??
918
+ hourRangeCadence(ir, minute, opts);
919
+
920
+ if (cadence !== null) {
921
+ return cadence;
922
+ }
923
+ }
924
+
782
925
  if (plan.times.length === 1) {
783
926
  const time = plan.times[0];
784
927
 
@@ -802,6 +945,18 @@ function renderCompactClockTimes(
802
945
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
803
946
  opts: NormalizedOptions
804
947
  ): string {
948
+ // An hour step (or arithmetic-progression hour list) under the single pinned
949
+ // minute reads as a cadence, not a wall of clock times. (Returns null for an
950
+ // irregular list or a range, which keep folding below.)
951
+ if (plan.fold) {
952
+ const cadence = hourCadence(ir, plan.minute, opts) ??
953
+ hourRangeCadence(ir, plan.minute, opts);
954
+
955
+ if (cadence !== null) {
956
+ return cadence;
957
+ }
958
+ }
959
+
805
960
  const hourSegs = ir.analyses.segments.hour!;
806
961
 
807
962
  // Range+isolated hours: join the isolated hour with "sekä klo" to stop it
@@ -815,7 +970,7 @@ function renderCompactClockTimes(
815
970
  ir.analyses.clockSecond, opts);
816
971
  }
817
972
 
818
- const phrase = bareMinutes(ir) + ' ' +
973
+ const phrase = bareMinutes(ir, opts) + ' ' +
819
974
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
820
975
  trailingQualifier(ir, opts);
821
976
 
@@ -829,11 +984,16 @@ function renderCompactClockTimes(
829
984
  hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
830
985
  }
831
986
 
832
- // A minute list over purely enumerated hours (step fires, all singles)
833
- // hours-first, drop "joka tunti".
834
- const hoursStr = hourSegmentTimes(ir, 0, null, opts);
835
- const phrase = hoursFirstMinutes(hoursStr, ir) +
836
- trailingQualifier(ir, opts);
987
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
988
+ // the bare minute clause ("minuuteilla 0, 25 ja 50, viiden tunnin välein klo
989
+ // 0–20"), not a wall of clock-time columns.
990
+ const cadence = unevenHourCadence(ir, opts);
991
+ const phrase = cadence ?
992
+ bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
993
+ // A minute list over purely enumerated hours (step fires, all singles) —
994
+ // hours-first, drop "joka tunti".
995
+ hoursFirstMinutes(hourSegmentTimes(ir, 0, null, opts), ir, opts) +
996
+ trailingQualifier(ir, opts);
837
997
 
838
998
  return ir.analyses.clockSecond ?
839
999
  secondsLeadClause(ir, opts) + ', ' + phrase :
@@ -864,8 +1024,82 @@ const renderers = {
864
1024
 
865
1025
  // --- Step phrases. ---
866
1026
 
1027
+ // A step cadence to phrase over a `cycle`-long field (60 for minute/second),
1028
+ // running from `start` to `last`.
1029
+ interface Stride {
1030
+ interval: number;
1031
+ start: number;
1032
+ last: number;
1033
+ cycle: number;
1034
+ unit: UnitForms;
1035
+ }
1036
+
1037
+ // Speak a step cadence over a `cycle`-long field. A clean stride from the top
1038
+ // of the cycle is the bare cadence ("viiden minuutin välein"); a uniform
1039
+ // offset (start within the first interval, the interval still dividing the
1040
+ // cycle) names only its start, since it wraps cleanly with no distinct
1041
+ // endpoint ("kuuden minuutin välein jokaisen tunnin minuutista 5 alkaen"); a
1042
+ // non-uniform stride (start >= interval, or an interval that does not divide
1043
+ // the cycle) pins both endpoints so the bounded, non-wrapping set reads
1044
+ // unambiguously ("kahden minuutin välein minuutista 3 minuuttiin 59"). This is
1045
+ // the one phrasing for every step the renderer speaks, whether the core kept
1046
+ // it a step shape (a clean cadence) or enumerated it to a fire list (an
1047
+ // offset/uneven set the list path recognizes as a progression).
1048
+ function renderStride(stride: Stride, opts: NormalizedOptions): string {
1049
+ const {interval, start, last, cycle, unit} = stride;
1050
+ const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
1051
+ const tiles = cycle % interval === 0;
1052
+
1053
+ if (start === 0 && tiles) {
1054
+ return cadence;
1055
+ }
1056
+
1057
+ if (start < interval && tiles) {
1058
+ return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
1059
+ ' alkaen';
1060
+ }
1061
+
1062
+ return cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last;
1063
+ }
1064
+
1065
+ // Speak a minute/second field's enumerated fires as a step cadence when they
1066
+ // form an arithmetic progression long enough to beat the list (the core
1067
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1068
+ // the renderer recognizes the progression). Returns null for a non-progression
1069
+ // or a too-short list, leaving the caller to enumerate.
1070
+ function strideFromSegments(
1071
+ segments: Segment[],
1072
+ unit: UnitForms,
1073
+ opts: NormalizedOptions
1074
+ ): string | null {
1075
+ const values = singleValues(segments);
1076
+ const step = values && arithmeticStep(values);
1077
+
1078
+ return step ?
1079
+ renderStride({...step, cycle: 60, unit}, opts) :
1080
+ null;
1081
+ }
1082
+
1083
+ // The sorted numeric values a field's segments cover, or null if any segment
1084
+ // is not a discrete single (a range or sub-step is not a plain fire list).
1085
+ function singleValues(segments: Segment[]): number[] | null {
1086
+ const values: number[] = [];
1087
+
1088
+ for (const segment of segments) {
1089
+ if (segment.kind !== 'single') {
1090
+ return null;
1091
+ }
1092
+
1093
+ values.push(+segment.value);
1094
+ }
1095
+
1096
+ return values;
1097
+ }
1098
+
867
1099
  // "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
868
- // "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen".
1100
+ // "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen". A step shape
1101
+ // only reaches here as a clean or uniform-offset cadence; an offset/uneven set
1102
+ // arrives as a fire list and is recognized by the list path instead.
869
1103
  function stepCycle60(
870
1104
  segment: StepSegment,
871
1105
  unit: UnitForms,
@@ -876,21 +1110,20 @@ function stepCycle60(
876
1110
  }
877
1111
 
878
1112
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
879
- const interval = segment.interval;
880
- const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
881
1113
 
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';
1114
+ // A short offset cadence still lists its fires; the stride phrasing names
1115
+ // the interval and offset only once there are enough fires to beat the list.
1116
+ if (start !== 0 && segment.fires.length <= 3) {
1117
+ return atMarks(joinList(wordList(segment.fires)), unit, true);
889
1118
  }
890
1119
 
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;
1120
+ return renderStride({
1121
+ interval: segment.interval,
1122
+ start,
1123
+ last: segment.fires[segment.fires.length - 1],
1124
+ cycle: 60,
1125
+ unit
1126
+ }, opts);
894
1127
  }
895
1128
 
896
1129
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
@@ -917,6 +1150,285 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
917
1150
  return cadence + ' klo ' + hourElatives[start] + ' alkaen';
918
1151
  }
919
1152
 
1153
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
1154
+
1155
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
1156
+ // from midnight is the bare cadence ("kahden tunnin välein"); a clean offset
1157
+ // names only its start ("kuuden tunnin välein klo 2:sta alkaen"); a bounded or
1158
+ // non-tiling stride pins both clock-time endpoints ("kahden tunnin välein klo
1159
+ // 9–17") so the bounded set reads unambiguously. Used wherever an hour step
1160
+ // (or arithmetic-progression hour list) would otherwise be cross-multiplied
1161
+ // into a wall of clock times.
1162
+ function hourStrideCadence(
1163
+ stride: {start: number; interval: number; last: number},
1164
+ opts: NormalizedOptions
1165
+ ): string {
1166
+ const {start, interval, last} = stride;
1167
+ const cadence = genitive(interval, opts) + ' tunnin välein';
1168
+ const tiles = 24 % interval === 0;
1169
+
1170
+ if (start === 0 && tiles) {
1171
+ return cadence;
1172
+ }
1173
+
1174
+ if (start < interval && tiles) {
1175
+ return cadence + ' klo ' + hourElatives[start] + ' alkaen';
1176
+ }
1177
+
1178
+ return cadence + ' ' +
1179
+ kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
1180
+ }
1181
+
1182
+ // An hour list's arithmetic progression, or null when its values are not a step
1183
+ // the renderer should speak as a cadence. The core rewrites a uneven hour step
1184
+ // (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
1185
+ // fire list, indistinguishable in the IR from a hand-written list; the renderer
1186
+ // recovers the cadence from the values. A progression starting at zero is a
1187
+ // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
1188
+ // a step when it is too long to be a deliberate clock-time list (9,17 is two
1189
+ // named times, not a cadence). Interval one is a plain range, never a step.
1190
+ function hourListStride(
1191
+ values: number[]
1192
+ ): {start: number; interval: number; last: number} | null {
1193
+ if (values.length < 2) {
1194
+ return null;
1195
+ }
1196
+
1197
+ const interval = values[1] - values[0];
1198
+
1199
+ if (interval < 2) {
1200
+ return null;
1201
+ }
1202
+
1203
+ for (let i = 2; i < values.length; i += 1) {
1204
+ if (values[i] - values[i - 1] !== interval) {
1205
+ return null;
1206
+ }
1207
+ }
1208
+
1209
+ if (values[0] !== 0 && values.length < 5) {
1210
+ return null;
1211
+ }
1212
+
1213
+ return {interval, last: values[values.length - 1], start: values[0]};
1214
+ }
1215
+
1216
+ // Whether an hour stride wraps the day cleanly from within its first interval
1217
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1218
+ // stride has no distinct endpoint and keeps its bare or "alkaen" cadence. Every
1219
+ // other stride — a uneven interval, or one starting at or past its interval (a
1220
+ // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1221
+ function offsetCleanStride(
1222
+ stride: {start: number; interval: number}
1223
+ ): boolean {
1224
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1225
+ }
1226
+
1227
+ // The hour field's stride, or null when the hour is not a cadence: a step
1228
+ // segment yields its {start, interval, last} directly; an all-single hour list
1229
+ // yields one only when its values form a step progression (so an irregular list
1230
+ // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1231
+ // the stride and speaks it as a cadence, not the clock-time cross-product.
1232
+ function hourStride(
1233
+ ir: IR
1234
+ ): {start: number; interval: number; last: number} | null {
1235
+ const segments = ir.analyses.segments.hour;
1236
+
1237
+ // A wildcard hour carries no segments (no discrete hours to stride over).
1238
+ if (!segments) {
1239
+ return null;
1240
+ }
1241
+
1242
+ if (segments.length === 1 && segments[0].kind === 'step') {
1243
+ const segment = segments[0];
1244
+
1245
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1246
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1247
+ if (segment.fires.length < 2) {
1248
+ return null;
1249
+ }
1250
+
1251
+ const start = segment.startToken === '*' ?
1252
+ 0 :
1253
+ +segment.startToken.split('-')[0];
1254
+
1255
+ return {interval: segment.interval, last: segment.fires[
1256
+ segment.fires.length - 1], start};
1257
+ }
1258
+
1259
+ const values = singleValues(segments);
1260
+
1261
+ return values && hourListStride(values);
1262
+ }
1263
+
1264
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1265
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1266
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1267
+ // enumerating list paths; there the bounded hour reads better as its cadence
1268
+ // ("…, viiden tunnin välein klo 0–20") than as a wall of clock times. An
1269
+ // offset-clean stride keeps its existing confinement form, so only the
1270
+ // endpoint-bearing case routes here.
1271
+ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1272
+ const stride = hourStride(ir);
1273
+
1274
+ if (!stride || offsetCleanStride(stride)) {
1275
+ return null;
1276
+ }
1277
+
1278
+ return hourStrideCadence(stride, opts);
1279
+ }
1280
+
1281
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1282
+ // fills the minute (a "minuutin ajan" frame at minute 0); a single 0 is just
1283
+ // the top of the minute (no clause); anything else needs its own clause.
1284
+ function subMinuteSecond(ir: IR): boolean {
1285
+ return ir.pattern.second === '*' || ir.shapes.second === 'step';
1286
+ }
1287
+
1288
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1289
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
1290
+ // list, range, or step second is counted at its own bare "kohdalla" mark (the
1291
+ // minute-0 is the top of the hour), and a wildcard second takes a "minuutin
1292
+ // ajan" frame (the whole minute-0 window). A non-zero minute is a real clock
1293
+ // minute: the second leads with its own clause (if any), then the minute reads
1294
+ // at its bare "kohdalla" mark.
1295
+ function hourCadenceLead(ir: IR, minute: number,
1296
+ opts: NormalizedOptions): string {
1297
+ if (minute === 0) {
1298
+ if (subMinuteSecond(ir)) {
1299
+ return secondsLeadClause(ir, opts) + ' minuutin ajan';
1300
+ }
1301
+
1302
+ return secondsLeadClause(ir, opts);
1303
+ }
1304
+
1305
+ const minutePhrase = atMarks(String(minute), units.minute, false);
1306
+
1307
+ // A single 0 second is just the top of the minute, so the minute leads
1308
+ // alone; any other second prefixes its own clause.
1309
+ if (ir.pattern.second === '0') {
1310
+ return minutePhrase;
1311
+ }
1312
+
1313
+ return secondsLeadClause(ir, opts) + ', ' + minutePhrase;
1314
+ }
1315
+
1316
+ // Render an hour step (or arithmetic-progression hour list) under a single
1317
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1318
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1319
+ // Returns null when the hour is not a stride (an irregular list, a single
1320
+ // hour, or a range), or when the cross-product is short enough that
1321
+ // enumeration is no longer than the cadence: a meaningful second makes every
1322
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
1323
+ // the stride must exceed the clock-time cap, the same point at which the core
1324
+ // itself stops enumerating. Renderer-only; the IR is unchanged.
1325
+ function hourCadence(ir: IR, minute: number,
1326
+ opts: NormalizedOptions): string | null {
1327
+ const stride = hourStride(ir);
1328
+
1329
+ if (!stride) {
1330
+ return null;
1331
+ }
1332
+
1333
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1334
+
1335
+ // A short stride that spells out as few clock times stays an enumeration only
1336
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1337
+ // or "alkaen" form is no shorter than the list. A bounded or uneven stride
1338
+ // has no clean wrap, so its endpoint-pinning cadence ("viiden tunnin välein
1339
+ // klo 0–20") reads better however short.
1340
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1341
+ offsetCleanStride(stride)) {
1342
+ return null;
1343
+ }
1344
+
1345
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1346
+ // stride is a confinement, not a juxtaposed cadence: it reads "minuutin ajan
1347
+ // joka toisen tunnin aikana", reusing the every-Nth-hour idiom so the
1348
+ // minute-0 window is never heard as the bare hour cadence.
1349
+ const segment = ir.analyses.segments.hour![0];
1350
+ const confined = minute === 0 && subMinuteSecond(ir) &&
1351
+ ir.analyses.segments.hour!.length === 1 && segment.kind === 'step' &&
1352
+ cleanHourStride(segment);
1353
+
1354
+ if (confined) {
1355
+ return secondsLeadClause(ir, opts) + ' minuutin ajan ' +
1356
+ everyNthHour(segment, opts) + trailingQualifier(ir, opts);
1357
+ }
1358
+
1359
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1360
+ // lead clause to fold in, so the bounded cadence stands on its own ("viiden
1361
+ // tunnin välein klo 0–20").
1362
+ if (minute === 0 && ir.pattern.second === '0') {
1363
+ return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1364
+ }
1365
+
1366
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1367
+ hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1368
+ }
1369
+
1370
+ // Whether an hour step is a clean stride over the whole day — unbounded,
1371
+ // dividing 24, and starting within the first interval — so it confines to "joka
1372
+ // N:nnen tunnin aikana" rather than enumerating its fires.
1373
+ function cleanHourStride(segment: StepSegment): boolean {
1374
+ if (segment.startToken.indexOf('-') !== -1) {
1375
+ return false;
1376
+ }
1377
+
1378
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1379
+
1380
+ return 24 % segment.interval === 0 && start < segment.interval;
1381
+ }
1382
+
1383
+ // Whether the hour field is a range — or a list whose segments include a
1384
+ // range — and so forms a window rather than a cross-product of clock times.
1385
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1386
+ // a step is handled by hourStride/hourCadence.
1387
+ function hasHourWindow(ir: IR): boolean {
1388
+ const segments = ir.analyses.segments.hour;
1389
+
1390
+ return !!segments && segments.some(function range(segment: Segment) {
1391
+ return segment.kind === 'range';
1392
+ });
1393
+ }
1394
+
1395
+ // The hour-range window as a cadence tail at the top of each hour: a lone
1396
+ // range is the bare "klo 9–17"; a range plus a non-contiguous hour joins it
1397
+ // with "sekä klo" ("klo 9–20 sekä klo 22"), the same idiom the bare folded
1398
+ // window uses. The minute has folded into the lead, so the window closes on
1399
+ // the top of its final hour.
1400
+ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1401
+ return ir.analyses.segments.hour!.length === 1 ?
1402
+ hourSegmentTimes(ir, 0, null, opts) :
1403
+ hourSegmentTimesWithSeka(ir, 0, null, opts);
1404
+ }
1405
+
1406
+ // Render an hour range (or a list whose segments include a range) under
1407
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1408
+ // then "klo 9–17" — instead of cross-multiplying the hours into a wall of
1409
+ // clock times. The hour-RANGE analog of hourCadence. Returns null when the
1410
+ // hour has no range, when the minute is non-zero (a real clock minute the
1411
+ // existing window form already speaks), or when a plain :00 set carries no
1412
+ // clause. Renderer-only; the IR is unchanged.
1413
+ function hourRangeCadence(ir: IR, minute: number,
1414
+ opts: NormalizedOptions): string | null {
1415
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1416
+ return null;
1417
+ }
1418
+
1419
+ const tail = hourRangeWindowTail(ir, opts);
1420
+
1421
+ // A wildcard or sub-minute step second is the whole minute-0 window
1422
+ // ("minuutin ajan", carried by hourCadenceLead), then the window — kept
1423
+ // distinct from the bare "joka tunti klo 9–17" so the confinement is never
1424
+ // heard as it (the hour-range analog of "minuutin ajan joka toisen tunnin
1425
+ // aikana"). A meaningful second leads at its mark, then the window.
1426
+ const joiner = subMinuteSecond(ir) ? ' ' : ', ';
1427
+
1428
+ return hourCadenceLead(ir, minute, opts) + joiner + tail +
1429
+ trailingQualifier(ir, opts);
1430
+ }
1431
+
920
1432
  // --- Hour-time phrasing. ---
921
1433
 
922
1434
  // On-the-hour fires as one klo phrase: "klo 0, 10 ja 20".
@@ -944,23 +1456,32 @@ function kloFromTimes(
944
1456
  return hourSegmentTimes(ir, 0, null, opts);
945
1457
  }
946
1458
 
947
- // Each fire hour as its own one-hour dash window under a single klo:
948
- // "klo 9.00–9.59 ja 17.00–17.59". Finnish prefers this to the English
949
- // "during the 9 a.m. and 5 p.m. hours" shape.
1459
+ // The hours accompanying a named-once minute clause under an hour list or
1460
+ // step. On-the-hour hours (a fires set, or a segment set with no real range)
1461
+ // are listed once "klo 0, 5, 10, 15 ja 20" — so the minute is never repeated
1462
+ // as a per-hour span. A real hour RANGE segment is a genuine span and keeps its
1463
+ // per-segment window ("klo 8.00–18.59 ja 22.00–22.59"), mirroring the other
1464
+ // languages, which list discrete hours but keep range windows.
950
1465
  function hourWindowsFromTimes(
951
1466
  ir: IR,
952
1467
  times: HourTimesPlan,
953
1468
  opts: NormalizedOptions
954
1469
  ): string {
955
1470
  if (times.kind === 'fires') {
956
- return 'klo ' + joinList(times.fires.map(function window(hour: number) {
957
- return hourWindowDigits(hour, opts);
958
- }));
1471
+ return kloList(times.fires, opts);
1472
+ }
1473
+
1474
+ const segments = ir.analyses.segments.hour!;
1475
+
1476
+ if (!segments.some(function ranged(segment: Segment) {
1477
+ return segment.kind === 'range';
1478
+ })) {
1479
+ return kloList(hourSegmentFires(segments), opts);
959
1480
  }
960
1481
 
961
1482
  const pieces: string[] = [];
962
1483
 
963
- ir.analyses.segments.hour!.forEach(function window(segment: Segment) {
1484
+ segments.forEach(function window(segment: Segment) {
964
1485
  if (segment.kind === 'range') {
965
1486
  pieces.push(rangeDigits({hour: +segment.bounds[0], minute: 0},
966
1487
  {hour: +segment.bounds[1], minute: 59}, opts));
@@ -978,6 +1499,23 @@ function hourWindowsFromTimes(
978
1499
  return 'klo ' + joinList(pieces);
979
1500
  }
980
1501
 
1502
+ // The on-the-hour fires of a range-free hour segment set, in order: a step
1503
+ // segment contributes its enumerated fires, a single its one value.
1504
+ function hourSegmentFires(segments: Segment[]): number[] {
1505
+ const hours: number[] = [];
1506
+
1507
+ segments.forEach(function each(segment: Segment) {
1508
+ if (segment.kind === 'step') {
1509
+ hours.push(...segment.fires);
1510
+ }
1511
+ else if (segment.kind === 'single') {
1512
+ hours.push(+segment.value);
1513
+ }
1514
+ });
1515
+
1516
+ return hours;
1517
+ }
1518
+
981
1519
  // "9.00–9.59": one hour as a dash window, in digits.
982
1520
  function hourWindowDigits(hour: number, opts: NormalizedOptions): string {
983
1521
  return rangeDigits({hour, minute: 0}, {hour, minute: 59}, opts);