cronli5 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@
10
10
  // case-pair construction wherever digits appear.
11
11
 
12
12
  import {clockDigits, numeral} from '../../core/format.js';
13
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
14
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
13
15
  import {resolveDialect} from './dialects.js';
14
16
  import type {
15
17
  ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -61,6 +63,7 @@ interface UnitForms {
61
63
  mark: string;
62
64
  anchor: string;
63
65
  ela: string;
66
+ ill: string;
64
67
  gen: string;
65
68
  }
66
69
 
@@ -159,16 +162,6 @@ const monthStems: (string | null)[] = [
159
162
  'joulu'
160
163
  ];
161
164
 
162
- // Cron token vocabulary (JAN..DEC, SUN..SAT) is part of cron syntax; map
163
- // it to field numbers.
164
- const monthTokens: {[token: string]: number} = {
165
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
166
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
167
- };
168
- const weekdayTokens: {[token: string]: number} = {
169
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
170
- };
171
-
172
165
  // Unit form tables for the anchored-minute/second constructions.
173
166
  // `mark` is the frequency for the "N minuutin kohdalla" ("at the
174
167
  // N-minute mark") form; `anchor` is the possessive for the elative
@@ -178,12 +171,14 @@ const units: {minute: UnitForms; second: UnitForms} = {
178
171
  mark: 'joka tunti',
179
172
  anchor: 'jokaisen tunnin',
180
173
  ela: 'minuutista',
174
+ ill: 'minuuttiin',
181
175
  gen: 'minuutin'
182
176
  },
183
177
  second: {
184
178
  mark: 'joka minuutti',
185
179
  anchor: 'jokaisen minuutin',
186
180
  ela: 'sekunnista',
181
+ ill: 'sekuntiin',
187
182
  gen: 'sekunnin'
188
183
  }
189
184
  };
@@ -304,54 +299,148 @@ function renderSecondsWithinMinute(
304
299
  atMarks(minuteField, units.minute, true) + trailingQualifier(ir, opts);
305
300
  }
306
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
+
307
361
  function renderComposeSeconds(
308
362
  ir: IR,
309
363
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
310
364
  opts: NormalizedOptions
311
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
+
312
376
  // When the rest is a minute-step cadence, the step leads and the second
313
377
  // anchor follows after a comma (the comma marks the granularity boundary
314
- // between the two levels, not a flat list). Build:
315
- // "[step phrase], [seconds][hour clause][trailing qualifier]".
316
- //
317
- // The minute-frequency phrase is reconstructed directly here so the hour
318
- // clause can be interleaved between the step and the second anchor without
319
- // duplicating the full renderMinuteFrequency logic. The hours-first reorder
320
- // that applies inside renderMinuteFrequency is intentionally NOT applied
321
- // here (the step-leads form is the correct shape for this construction).
378
+ // between the two levels, not a flat list).
322
379
  if (plan.rest.kind === 'minuteFrequency' && ir.pattern.second !== '*') {
323
- const freq = plan.rest as Extract<PlanNode, {kind: 'minuteFrequency'}>;
324
- const seg = stepSegment(ir.analyses.segments.minute!);
325
- const stepPhrase = stepCycle60(seg, units.minute, opts);
326
- let hourClause = '';
327
-
328
- if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
329
- // The step renders as an anchored kohdalla list rather than a cadence,
330
- // so the hours-first reorder applies here too: bare hours lead, minute
331
- // anchors follow, then the seconds clause.
332
- const bareHours = kloFromTimes(ir, freq.hours.times, opts);
333
-
334
- return hoursFirstMinutes(bareHours, ir) + ', ' +
335
- secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
336
- }
337
- else if (freq.hours.kind === 'during' && !minuteStepIsAnchored(seg)) {
338
- hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
339
- }
340
- else if (freq.hours.kind === 'window') {
341
- hourClause = ' ' + hourWindow(freq.hours, opts);
342
- }
343
- else if (freq.hours.kind === 'step') {
344
- hourClause = ' ' +
345
- everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
346
- }
380
+ return composeSecondsOverMinuteStep(ir, plan.rest, opts);
381
+ }
347
382
 
348
- return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
349
- hourClause + trailingQualifier(ir, opts);
383
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
384
+ // clock-time rest would read "klo 9", dropping the pinned :00 and so the
385
+ // one-minute confinement (60 fires in :00, not 3,600 across the hour). Bind
386
+ // the seconds to the explicit clock minute with the "minuutin HH.00 aikana"
387
+ // frame (an "of"/during form, never a range) and trail the day qualifier
388
+ // ("joka sekunti minuutin 9.00 aikana, joka päivä").
389
+ if (plan.rest.kind === 'clockTimes' &&
390
+ plan.rest.times.every((time) => +time.minute === 0)) {
391
+ return composeMinuteZero(ir, plan.rest, opts);
392
+ }
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';
350
401
  }
351
402
 
352
403
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
353
404
  }
354
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
+
422
+ // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
423
+ // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
424
+ // a range — a range would round-trip back to the whole hour) and trail the day
425
+ // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
426
+ function composeMinuteZero(
427
+ ir: IR,
428
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
429
+ opts: NormalizedOptions
430
+ ): string {
431
+ const clocks = rest.times.map(function clock(time): string {
432
+ return clockDigits({hour: time.hour, minute: time.minute},
433
+ {sep: opts.style.sep});
434
+ });
435
+ const frame = clocks.length === 1 ?
436
+ 'minuutin ' + clocks[0] :
437
+ 'minuuttien ' + joinList(clocks);
438
+ const dayTrail = leadingQualifier(ir, opts).trimEnd();
439
+
440
+ return secondsLeadClause(ir, opts) + ' ' + frame + ' aikana' +
441
+ (dayTrail ? ', ' + dayTrail : '');
442
+ }
443
+
355
444
  // The leading clause describing a second field relative to the minute.
356
445
  function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
357
446
  const secondField = ir.pattern.second;
@@ -375,8 +464,11 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
375
464
  return atMarks(secondField, units.second, marked);
376
465
  }
377
466
 
378
- return atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
379
- 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);
380
472
  }
381
473
 
382
474
  // --- Minute renderers. ---
@@ -403,7 +495,7 @@ function renderRangeOfMinutes(
403
495
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
404
496
  opts: NormalizedOptions
405
497
  ): string {
406
- return minutesList(ir) + trailingQualifier(ir, opts);
498
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
407
499
  }
408
500
 
409
501
  function renderMultipleMinutes(
@@ -411,21 +503,27 @@ function renderMultipleMinutes(
411
503
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
412
504
  opts: NormalizedOptions
413
505
  ): string {
414
- return minutesList(ir) + trailingQualifier(ir, opts);
506
+ return minutesList(ir, opts) + trailingQualifier(ir, opts);
415
507
  }
416
508
 
417
- // "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range).
418
- function minutesList(ir: IR): string {
419
- return atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
420
- 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);
421
517
  }
422
518
 
423
519
  // The bare minute mark, for clauses where a specific hour follows and
424
520
  // the "joka tunti" frequency would be redundant: "0–30 minuutin
425
- // kohdalla".
426
- function bareMinutes(ir: IR): string {
427
- return atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
428
- 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);
429
527
  }
430
528
 
431
529
  // Whether a minute step renders as an anchored "kohdalla" clause rather
@@ -475,7 +573,21 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
475
573
  // (plural genitive "minuuttien"; replaces the leading "joka tunti"). Used
476
574
  // when a range or multi-point minute list over enumerated hours renders
477
575
  // hours-first.
478
- 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
+
479
591
  return hoursStr + ' aina minuuttien ' +
480
592
  joinList(segmentWords(ir.analyses.segments.minute!)) + ' kohdalla';
481
593
  }
@@ -521,7 +633,8 @@ function renderMinuteFrequency(
521
633
  if (minuteStepIsAnchored(seg)) {
522
634
  const bareHours = kloFromTimes(ir, plan.hours.times, opts);
523
635
 
524
- return hoursFirstMinutes(bareHours, ir) + trailingQualifier(ir, opts);
636
+ return hoursFirstMinutes(bareHours, ir, opts) +
637
+ trailingQualifier(ir, opts);
525
638
  }
526
639
 
527
640
  return stepCycle60(seg, units.minute, opts) + ' ' +
@@ -542,12 +655,20 @@ function renderMinuteFrequency(
542
655
  return phrase + trailingQualifier(ir, opts);
543
656
  }
544
657
 
545
- // "joka minuutti klo 9.00–9.59".
658
+ // "joka minuutti klo 9.00–9.59". A wildcard minute is the whole hour, so it
659
+ // reads as that hour itself ("joka minuutti kello 9 aikana") rather than a
660
+ // synthesized "klo 9.00–9.59" range the source never stated; a plain range is
661
+ // a real window and keeps the dash form.
546
662
  function renderMinuteSpanInHour(
547
663
  ir: IR,
548
664
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
549
665
  opts: NormalizedOptions
550
666
  ): string {
667
+ if (ir.pattern.minute === '*') {
668
+ return 'joka minuutti kello ' + plan.hour + ' aikana' +
669
+ trailingQualifier(ir, opts);
670
+ }
671
+
551
672
  return 'joka minuutti ' +
552
673
  kloRange({hour: plan.hour, minute: plan.span[0]},
553
674
  {hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -573,7 +694,7 @@ function renderMinutesAcrossHours(
573
694
 
574
695
  // Range+isolated hours: minute-first, bare minutes, sekä klo.
575
696
  if (hoursAreRangeIsolated(ir.analyses.segments.hour!)) {
576
- return bareMinutes(ir) + ' ' +
697
+ return bareMinutes(ir, opts) + ' ' +
577
698
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
578
699
  trailingQualifier(ir, opts);
579
700
  }
@@ -583,7 +704,7 @@ function renderMinutesAcrossHours(
583
704
  // shows it).
584
705
  const hoursStr = kloFromTimes(ir, plan.times, opts);
585
706
 
586
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
707
+ return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
587
708
  }
588
709
 
589
710
  function renderMinuteSpanAcrossHourStep(
@@ -611,10 +732,10 @@ function renderMinuteSpanAcrossHourStep(
611
732
  if (segment.startToken.indexOf('-') !== -1) {
612
733
  const hoursStr = kloList(segment.fires, opts);
613
734
 
614
- return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
735
+ return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
615
736
  }
616
737
 
617
- return bareMinutes(ir) + hourStepTail(segment, opts) +
738
+ return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
618
739
  trailingQualifier(ir, opts);
619
740
  }
620
741
 
@@ -692,7 +813,7 @@ function renderHourRange(
692
813
  // A minute range over a single hour range renders hours-first
693
814
  // ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
694
815
  if (plan.minuteForm === 'range') {
695
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
816
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
696
817
  }
697
818
 
698
819
  // On the hour the window joins directly ("joka tunti klo 9–17"); a
@@ -711,7 +832,7 @@ function renderHourRange(
711
832
 
712
833
  // A minute list (≥2 values) over a single hour range renders hours-first
713
834
  // ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
714
- return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
835
+ return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
715
836
  }
716
837
 
717
838
  function renderHourStep(
@@ -746,6 +867,16 @@ function renderClockTimes(
746
867
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
747
868
  opts: NormalizedOptions
748
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
+
749
880
  if (plan.times.length === 1) {
750
881
  const time = plan.times[0];
751
882
 
@@ -769,6 +900,17 @@ function renderCompactClockTimes(
769
900
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
770
901
  opts: NormalizedOptions
771
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
+
772
914
  const hourSegs = ir.analyses.segments.hour!;
773
915
 
774
916
  // Range+isolated hours: join the isolated hour with "sekä klo" to stop it
@@ -782,7 +924,7 @@ function renderCompactClockTimes(
782
924
  ir.analyses.clockSecond, opts);
783
925
  }
784
926
 
785
- const phrase = bareMinutes(ir) + ' ' +
927
+ const phrase = bareMinutes(ir, opts) + ' ' +
786
928
  hourSegmentTimesWithSeka(ir, 0, null, opts) +
787
929
  trailingQualifier(ir, opts);
788
930
 
@@ -799,7 +941,7 @@ function renderCompactClockTimes(
799
941
  // A minute list over purely enumerated hours (step fires, all singles) —
800
942
  // hours-first, drop "joka tunti".
801
943
  const hoursStr = hourSegmentTimes(ir, 0, null, opts);
802
- const phrase = hoursFirstMinutes(hoursStr, ir) +
944
+ const phrase = hoursFirstMinutes(hoursStr, ir, opts) +
803
945
  trailingQualifier(ir, opts);
804
946
 
805
947
  return ir.analyses.clockSecond ?
@@ -831,8 +973,82 @@ const renderers = {
831
973
 
832
974
  // --- Step phrases. ---
833
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
+
834
1048
  // "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
835
- // "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.
836
1052
  function stepCycle60(
837
1053
  segment: StepSegment,
838
1054
  unit: UnitForms,
@@ -843,21 +1059,20 @@ function stepCycle60(
843
1059
  }
844
1060
 
845
1061
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
846
- const interval = segment.interval;
847
- const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
848
1062
 
849
- if (start !== 0) {
850
- if (segment.fires.length <= 3) {
851
- return atMarks(joinList(wordList(segment.fires)), unit, true);
852
- }
853
-
854
- return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
855
- ' 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);
856
1067
  }
857
1068
 
858
- // A clean stride from the top of the cycle is the bare cadence. (An uneven
859
- // stride is rewritten to its fires upstream and never reaches here.)
860
- 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);
861
1076
  }
862
1077
 
863
1078
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
@@ -884,6 +1099,156 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
884
1099
  return cadence + ' klo ' + hourElatives[start] + ' alkaen';
885
1100
  }
886
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
+
887
1252
  // --- Hour-time phrasing. ---
888
1253
 
889
1254
  // On-the-hour fires as one klo phrase: "klo 0, 10 ja 20".
@@ -1339,18 +1704,16 @@ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
1339
1704
  }
1340
1705
  }
1341
1706
 
1342
- // Resolve a weekday token or number to its table index.
1707
+ // Resolve a weekday to its table index. Weekday-field segments are already
1708
+ // canonical numbers; a Quartz stem (`5L`, `MON#2`) is not, so resolve any
1709
+ // name via the core's index (with the Sunday alias 7 folding to 0).
1343
1710
  function weekdayNumber(token: string | number): number {
1344
- if (token in weekdayTokens) {
1345
- return weekdayTokens[token];
1346
- }
1347
-
1348
- return +token % 7;
1711
+ return toFieldNumber('' + token, weekdayNumbers) % 7;
1349
1712
  }
1350
1713
 
1351
- // Resolve a month token or number to its table index.
1714
+ // Resolve a canonical month number to its table index.
1352
1715
  function monthNumber(token: string | number): number {
1353
- return monthTokens[token] || +token;
1716
+ return +token;
1354
1717
  }
1355
1718
 
1356
1719
  // --- Years. ---
@@ -1492,7 +1855,10 @@ const fi: Language = {
1492
1855
  fallback: 'tunnistamaton cron-lauseke',
1493
1856
  options: normalizeOptions,
1494
1857
  reboot: 'järjestelmän käynnistyessä',
1495
- sentence: (description) => 'Suoritetaan ' + description + '.'
1858
+ // A description ending in a period already carries it, so closing the
1859
+ // sentence must not double it.
1860
+ sentence: (description) =>
1861
+ 'Suoritetaan ' + description + (description.endsWith('.') ? '' : '.')
1496
1862
  };
1497
1863
 
1498
1864
  export default fi;