cronli5 0.2.0 → 0.3.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +4 -4
  3. package/cronli5.min.js +2 -2
  4. package/dist/cronli5.cjs +514 -407
  5. package/dist/cronli5.js +514 -407
  6. package/dist/lang/de.cjs +296 -225
  7. package/dist/lang/de.js +296 -225
  8. package/dist/lang/en.cjs +471 -364
  9. package/dist/lang/en.js +471 -364
  10. package/dist/lang/es.cjs +318 -281
  11. package/dist/lang/es.js +318 -281
  12. package/dist/lang/fi.cjs +326 -276
  13. package/dist/lang/fi.js +326 -276
  14. package/dist/lang/zh.cjs +308 -236
  15. package/dist/lang/zh.js +308 -236
  16. package/package.json +1 -1
  17. package/src/core/analyze.ts +22 -21
  18. package/src/core/cadence.ts +164 -0
  19. package/src/core/index.ts +3 -1
  20. package/src/core/normalize.ts +3 -3
  21. package/src/core/parse.ts +1 -1
  22. package/src/core/{ir.ts → schedule.ts} +23 -24
  23. package/src/core/shapes.ts +8 -1
  24. package/src/core/specs.ts +1 -1
  25. package/src/core/util.ts +4 -83
  26. package/src/core/validate.ts +2 -2
  27. package/src/core/weekday.ts +54 -0
  28. package/src/cronli5.ts +7 -7
  29. package/src/lang/de/index.ts +329 -288
  30. package/src/lang/en/dialects.ts +1 -1
  31. package/src/lang/en/index.ts +640 -516
  32. package/src/lang/es/index.ts +342 -374
  33. package/src/lang/es/notes.md +1 -1
  34. package/src/lang/fi/dialects.ts +1 -1
  35. package/src/lang/fi/index.ts +367 -372
  36. package/src/lang/fi/notes.md +23 -8
  37. package/src/lang/fi/status.json +1 -1
  38. package/src/lang/zh/index.ts +344 -262
  39. package/src/types.ts +6 -6
  40. package/types/core/analyze.d.ts +4 -4
  41. package/types/core/cadence.d.ts +33 -0
  42. package/types/core/index.d.ts +3 -1
  43. package/types/core/normalize.d.ts +1 -1
  44. package/types/core/parse.d.ts +1 -1
  45. package/types/core/{ir.d.ts → schedule.d.ts} +16 -21
  46. package/types/core/shapes.d.ts +2 -1
  47. package/types/core/specs.d.ts +1 -1
  48. package/types/core/util.d.ts +1 -15
  49. package/types/core/weekday.d.ts +10 -0
  50. package/types/lang/de/index.d.ts +1 -1
  51. package/types/lang/en/dialects.d.ts +1 -1
  52. package/types/lang/en/index.d.ts +1 -1
  53. package/types/lang/es/index.d.ts +1 -1
  54. package/types/lang/fi/dialects.d.ts +1 -1
  55. package/types/lang/fi/index.d.ts +1 -1
  56. package/types/lang/zh/index.d.ts +1 -1
  57. package/types/types.d.ts +5 -5
@@ -1,4 +1,4 @@
1
- // The Finnish language module: renders an analyzed cron pattern (the IR
1
+ // The Finnish language module: renders an analyzed cron pattern (the Schedule
2
2
  // produced by core `analyze`) as natural Finnish. Anchored to
3
3
  // Kielitoimiston ohjepankki and SFS 4175; see notes.md.
4
4
  //
@@ -11,14 +11,18 @@
11
11
 
12
12
  import {clockDigits, numeral} from '../../core/format.js';
13
13
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
14
+ import {isOpenStep} from '../../core/shapes.js';
14
15
  import {
15
- arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
16
- } from '../../core/util.js';
16
+ arithmeticStep, hourListStride, offsetCleanStride,
17
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
18
+ } from '../../core/cadence.js';
19
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
20
+ import {toFieldNumber} from '../../core/util.js';
17
21
  import {resolveDialect} from './dialects.js';
18
22
  import type {
19
- ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
23
+ ClockTime, HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
20
24
  Segment
21
- } from '../../core/ir.js';
25
+ } from '../../core/schedule.js';
22
26
  import type {Cronli5Options} from '../../types.js';
23
27
 
24
28
  // A step segment, the only Segment variant carrying `startToken`,
@@ -39,12 +43,6 @@ interface HourWindow {
39
43
  last: number;
40
44
  }
41
45
 
42
- // The first segment of a step field, narrowed to its step variant. Step
43
- // shapes always classify their (single) segment as a step.
44
- function stepSegment(segments: Segment[]): StepSegment {
45
- return segments[0] as StepSegment;
46
- }
47
-
48
46
  // A `{hour, minute, second?}` time end for the digit/range helpers.
49
47
  interface TimeParts {
50
48
  hour: number;
@@ -211,94 +209,134 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
211
209
 
212
210
  // A restricted-month date-or-weekday union: both the date and weekday are
213
211
  // restricted AND the month is restricted. When true, the month leads so it
214
- // scopes both arms, and the joko…tai union comes last.
215
- function restrictedMonthUnion(ir: IR): boolean {
216
- return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
217
- ir.pattern.month !== '*';
212
+ // scopes both arms, and the inclusive "tai" union comes last.
213
+ function restrictedMonthUnion(schedule: Schedule): boolean {
214
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*' &&
215
+ schedule.pattern.month !== '*';
216
+ }
217
+
218
+ // An open interval-2 day-of-month step covers the odd days (1, 3, 5, …, 31),
219
+ // resetting each month — so in a union arm it reads as the parity class
220
+ // "kuukauden parittomina päivinä" (the essive plural of "odd days of the
221
+ // month"), never the continuous "joka toinen päivä" (which implies an
222
+ // unbroken 48-hour cycle across month boundaries) nor a 16-date enumeration
223
+ // that would bury the union beside the "tai". `*/2` and `1/2` are the odd
224
+ // days. Mirrors en's odd-numbered-day idiom; null when not such a step.
225
+ function oddDayUnion(dateField: string): string | null {
226
+ if (!isOpenStep(dateField)) {
227
+ return null;
228
+ }
229
+
230
+ const [start, step] = dateField.split('/');
231
+
232
+ return (start === '*' || start === '1') && +step === 2 ?
233
+ 'kuukauden parittomina päivinä' :
234
+ null;
218
235
  }
219
236
 
220
- // The DOM arm of a restricted-month joko…tai union. Under a fronted month
221
- // an ordinary date drops the generic "kuukauden" anchor; a Quartz date
222
- // keeps its idiom unchanged.
223
- function unionDateArm(ir: IR): string {
224
- return quartzDatePhrase(ir.pattern.date) ||
225
- dateWords(ir) + ' päivänä';
237
+ // The DOM arm of a restricted-month union. Under a fronted month an ordinary
238
+ // date drops the generic "kuukauden" anchor; a Quartz date keeps its idiom
239
+ // unchanged; an open `*/2` step reads as the odd-day parity class.
240
+ function unionDateArm(schedule: Schedule): string {
241
+ return quartzDatePhrase(schedule.pattern.date) ||
242
+ oddDayUnion(schedule.pattern.date) ||
243
+ dateWords(schedule) + ' päivänä';
226
244
  }
227
245
 
228
- // Render an analyzed cron pattern (the IR) as Finnish.
229
- function describe(ir: IR, opts: NormalizedOptions): string {
246
+ // The weekday arm of a union. A Monday-through-Friday range reads as the
247
+ // recurring weekday class "arkisin" (= weekdays), parallel to the recurring
248
+ // date arm beside it; everything else defers to the general weekday qualifier.
249
+ function unionWeekdayArm(schedule: Schedule): string {
250
+ const segments = segmentsOf(schedule, 'weekday');
251
+
252
+ if (segments.length === 1 && segments[0].kind === 'range' &&
253
+ segments[0].bounds[0] === '1' && segments[0].bounds[1] === '5') {
254
+ return 'arkisin';
255
+ }
256
+
257
+ return weekdayQualifier(schedule);
258
+ }
259
+
260
+ // Render an analyzed cron pattern (the Schedule) as Finnish.
261
+ function describe(schedule: Schedule, opts: NormalizedOptions): string {
230
262
  // A restricted-month date-or-weekday union: the month leads so it scopes
231
- // both arms, then the joko…tai union comes last.
232
- if (restrictedMonthUnion(ir)) {
233
- const timePart = render(ir, ir.plan, opts);
263
+ // both arms, then the inclusive "tai" union comes last. Finnish "joko … tai"
264
+ // is the EXCLUSIVE disjunction (only one of the two), so it cannot express
265
+ // cron's union plain "tai" reads inclusively.
266
+ if (restrictedMonthUnion(schedule)) {
267
+ const timePart = render(schedule, schedule.plan, opts);
234
268
 
235
269
  return applyYear(
236
- monthPhrase(ir) + ' ' + timePart +
237
- ' joko ' + unionDateArm(ir) + ' tai ' + weekdayQualifier(ir),
238
- ir,
270
+ monthPhrase(schedule) + ' ' + timePart +
271
+ ' ' + unionDateArm(schedule) + ' tai ' +
272
+ unionWeekdayArm(schedule),
273
+ schedule,
239
274
  opts
240
275
  );
241
276
  }
242
277
 
243
- return applyYear(render(ir, ir.plan, opts), ir, opts);
278
+ return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
244
279
  }
245
280
 
246
281
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
247
- function render(ir: IR, plan: PlanNode, opts: NormalizedOptions): string {
282
+ function render(
283
+ schedule: Schedule, plan: PlanNode, opts: NormalizedOptions
284
+ ): string {
248
285
  // The renderers map each handles one `kind`; the dispatch indexes the
249
286
  // union, which TypeScript cannot narrow per-key, so the lookup is cast
250
287
  // to a renderer accepting this node's plan.
251
288
  const renderer = renderers[plan.kind] as
252
- (ir: IR, plan: PlanNode, opts: NormalizedOptions) => string;
289
+ (schedule: Schedule, plan: PlanNode, opts: NormalizedOptions) => string;
253
290
 
254
- return renderer(ir, plan, opts);
291
+ return renderer(schedule, plan, opts);
255
292
  }
256
293
 
257
294
  // --- Seconds renderers. ---
258
295
 
259
296
  function renderEverySecond(
260
- ir: IR,
297
+ schedule: Schedule,
261
298
  plan: Extract<PlanNode, {kind: 'everySecond'}>,
262
299
  opts: NormalizedOptions
263
300
  ): string {
264
- return 'joka sekunti' + trailingQualifier(ir, opts);
301
+ return 'joka sekunti' + trailingQualifier(schedule, opts);
265
302
  }
266
303
 
267
304
  function renderStandaloneSeconds(
268
- ir: IR,
305
+ schedule: Schedule,
269
306
  plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
270
307
  opts: NormalizedOptions
271
308
  ): string {
272
- return secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
309
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
273
310
  }
274
311
 
275
312
  function renderSecondPastMinute(
276
- ir: IR,
313
+ schedule: Schedule,
277
314
  plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
278
315
  opts: NormalizedOptions
279
316
  ): string {
280
- return atMarks(ir.pattern.second, units.second, true) +
281
- trailingQualifier(ir, opts);
317
+ return atMarks(schedule.pattern.second, units.second, true) +
318
+ trailingQualifier(schedule, opts);
282
319
  }
283
320
 
284
321
  // A meaningful second combined with a single specific minute (and an
285
322
  // open hour): a single second folds into one shared "kohdalla"; a list,
286
323
  // range, or step leads with its own clause.
287
324
  function renderSecondsWithinMinute(
288
- ir: IR,
325
+ schedule: Schedule,
289
326
  plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
290
327
  opts: NormalizedOptions
291
328
  ): string {
292
- const minuteField = ir.pattern.minute;
329
+ const minuteField = schedule.pattern.minute;
293
330
 
294
331
  if (plan.singleSecond) {
295
332
  return units.minute.mark + ' ' + minuteField + ' ' +
296
- units.minute.gen + ' ja ' + ir.pattern.second + ' ' +
297
- units.second.gen + ' kohdalla' + trailingQualifier(ir, opts);
333
+ units.minute.gen + ' ja ' + schedule.pattern.second + ' ' +
334
+ units.second.gen + ' kohdalla' + trailingQualifier(schedule, opts);
298
335
  }
299
336
 
300
- return secondsLeadClause(ir, opts) + ', ' +
301
- atMarks(minuteField, units.minute, true) + trailingQualifier(ir, opts);
337
+ return secondsLeadClause(schedule, opts) + ', ' +
338
+ atMarks(minuteField, units.minute, true) +
339
+ trailingQualifier(schedule, opts);
302
340
  }
303
341
 
304
342
  // A meaningful second composed over a minute-step cadence: the step leads and
@@ -309,38 +347,38 @@ function renderSecondsWithinMinute(
309
347
  // renderMinuteFrequency logic; its hours-first reorder is intentionally NOT
310
348
  // applied (the step-leads form is the correct shape for this construction).
311
349
  function composeSecondsOverMinuteStep(
312
- ir: IR,
350
+ schedule: Schedule,
313
351
  freq: Extract<PlanNode, {kind: 'minuteFrequency'}>,
314
352
  opts: NormalizedOptions
315
353
  ): string {
316
- const seg = stepSegment(ir.analyses.segments.minute!);
354
+ const seg = stepSegment(schedule, 'minute');
317
355
  const stepPhrase = stepCycle60(seg, units.minute, opts);
318
356
 
319
357
  if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
320
358
  // The step renders as an anchored kohdalla list rather than a cadence, so
321
359
  // the hours-first reorder applies here too: bare hours lead, minute anchors
322
360
  // follow, then the seconds clause.
323
- const bareHours = kloFromTimes(ir, freq.hours.times, opts);
361
+ const bareHours = kloFromTimes(schedule, freq.hours.times, opts);
324
362
 
325
- return hoursFirstMinutes(bareHours, ir, opts) + ', ' +
326
- secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
363
+ return hoursFirstMinutes(bareHours, schedule, opts) + ', ' +
364
+ secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
327
365
  }
328
366
 
329
367
  let hourClause = '';
330
368
 
331
369
  if (freq.hours.kind === 'during') {
332
- hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
370
+ hourClause = ' ' + hourWindowsFromTimes(schedule, freq.hours.times, opts);
333
371
  }
334
372
  else if (freq.hours.kind === 'window') {
335
373
  hourClause = ' ' + hourWindow(freq.hours, opts);
336
374
  }
337
375
  else if (freq.hours.kind === 'step') {
338
376
  hourClause = ' ' +
339
- everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
377
+ everyNthHour(stepSegment(schedule, 'hour'), opts);
340
378
  }
341
379
 
342
- return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
343
- hourClause + trailingQualifier(ir, opts);
380
+ return stepPhrase + ', ' + secondsLeadClause(schedule, opts) +
381
+ hourClause + trailingQualifier(schedule, opts);
344
382
  }
345
383
 
346
384
  // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
@@ -348,24 +386,25 @@ function composeSecondsOverMinuteStep(
348
386
  // when that does not apply (a non-clock rest, a multi-valued minute, or an
349
387
  // hour that is not a stride).
350
388
  function composeHourCadence(
351
- ir: IR,
389
+ schedule: Schedule,
352
390
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
353
391
  opts: NormalizedOptions
354
392
  ): string | null {
355
393
  const clockRest = plan.rest.kind === 'clockTimes' ||
356
394
  plan.rest.kind === 'compactClockTimes';
357
395
 
358
- if (!clockRest || ir.shapes.minute !== 'single') {
396
+ if (!clockRest || schedule.shapes.minute !== 'single') {
359
397
  return null;
360
398
  }
361
399
 
362
- const minute = +ir.pattern.minute;
400
+ const minute = +schedule.pattern.minute;
363
401
 
364
- return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
402
+ return hourCadence(schedule, minute, opts) ??
403
+ hourRangeCadence(schedule, minute, opts);
365
404
  }
366
405
 
367
406
  function renderComposeSeconds(
368
- ir: IR,
407
+ schedule: Schedule,
369
408
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
370
409
  opts: NormalizedOptions
371
410
  ): string {
@@ -373,7 +412,7 @@ function renderComposeSeconds(
373
412
  // minute is a cadence, not a wall of clock times: the second/minute lead,
374
413
  // then the hour cadence ("30 sekunnin kohdalla, kahden tunnin välein"). The
375
414
  // clock-time rest would otherwise cross-multiply the hours.
376
- const cadence = composeHourCadence(ir, plan, opts);
415
+ const cadence = composeHourCadence(schedule, plan, opts);
377
416
 
378
417
  if (cadence !== null) {
379
418
  return cadence;
@@ -382,8 +421,8 @@ function renderComposeSeconds(
382
421
  // When the rest is a minute-step cadence, the step leads and the second
383
422
  // anchor follows after a comma (the comma marks the granularity boundary
384
423
  // between the two levels, not a flat list).
385
- if (plan.rest.kind === 'minuteFrequency' && ir.pattern.second !== '*') {
386
- return composeSecondsOverMinuteStep(ir, plan.rest, opts);
424
+ if (plan.rest.kind === 'minuteFrequency' && schedule.pattern.second !== '*') {
425
+ return composeSecondsOverMinuteStep(schedule, plan.rest, opts);
387
426
  }
388
427
 
389
428
  // A sub-minute second with the minute pinned to 0 and a specific hour: the
@@ -394,7 +433,7 @@ function renderComposeSeconds(
394
433
  // ("joka sekunti minuutin 9.00 aikana, joka päivä").
395
434
  if (plan.rest.kind === 'clockTimes' &&
396
435
  plan.rest.times.every((time) => +time.minute === 0)) {
397
- return composeMinuteZero(ir, plan.rest, opts);
436
+ return composeMinuteZero(schedule, plan.rest, opts);
398
437
  }
399
438
 
400
439
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
@@ -402,8 +441,8 @@ function renderComposeSeconds(
402
441
  // välein"). Bind them as "every second of every other minute" ("joka sekunti
403
442
  // joka toisena minuuttina"), mirroring English. Other strides, a restricted
404
443
  // hour, and an hour cadence keep the juxtaposed form.
405
- if (isEveryOtherMinuteSeconds(ir, plan)) {
406
- return secondsLeadClause(ir, opts) + ' joka toisena minuuttina';
444
+ if (isEveryOtherMinuteSeconds(schedule, plan)) {
445
+ return secondsLeadClause(schedule, opts) + ' joka toisena minuuttina';
407
446
  }
408
447
 
409
448
  // A compact clock-time rest folds a meaningful SINGLE second into its own
@@ -411,24 +450,24 @@ function renderComposeSeconds(
411
450
  // double it. A wildcard or stepped second is not folded there (no
412
451
  // clockSecond), so it still leads its own clause here.
413
452
  const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
414
- ir.analyses.clockSecond;
415
- const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
453
+ schedule.analyses.clockSecond;
454
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
416
455
 
417
- return lead + render(ir, plan.rest, opts);
456
+ return lead + render(schedule, plan.rest, opts);
418
457
  }
419
458
 
420
459
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
421
460
  // cadences read as contradictory side by side, so they bind into one.
422
461
  function isEveryOtherMinuteSeconds(
423
- ir: IR,
462
+ schedule: Schedule,
424
463
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
425
464
  ): boolean {
426
- if (plan.rest.kind !== 'minuteFrequency' || ir.pattern.second !== '*' ||
427
- ir.shapes.hour !== 'wildcard') {
465
+ if (plan.rest.kind !== 'minuteFrequency' || schedule.pattern.second !== '*' ||
466
+ schedule.shapes.hour !== 'wildcard') {
428
467
  return false;
429
468
  }
430
469
 
431
- const seg = stepSegment(ir.analyses.segments.minute!);
470
+ const seg = stepSegment(schedule, 'minute');
432
471
 
433
472
  return seg.startToken === '*' && seg.interval === 2;
434
473
  }
@@ -438,7 +477,7 @@ function isEveryOtherMinuteSeconds(
438
477
  // a range — a range would round-trip back to the whole hour) and trail the day
439
478
  // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
440
479
  function composeMinuteZero(
441
- ir: IR,
480
+ schedule: Schedule,
442
481
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
443
482
  opts: NormalizedOptions
444
483
  ): string {
@@ -449,16 +488,18 @@ function composeMinuteZero(
449
488
  const frame = clocks.length === 1 ?
450
489
  'minuutin ' + clocks[0] :
451
490
  'minuuttien ' + joinList(clocks);
452
- const dayTrail = leadingQualifier(ir, opts).trimEnd();
491
+ const dayTrail = leadingQualifier(schedule, opts).trimEnd();
453
492
 
454
- return secondsLeadClause(ir, opts) + ' ' + frame + ' aikana' +
493
+ return secondsLeadClause(schedule, opts) + ' ' + frame + ' aikana' +
455
494
  (dayTrail ? ', ' + dayTrail : '');
456
495
  }
457
496
 
458
497
  // The leading clause describing a second field relative to the minute.
459
- function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
460
- const secondField = ir.pattern.second;
461
- const shape = ir.shapes.second;
498
+ function secondsLeadClause(
499
+ schedule: Schedule, opts: NormalizedOptions
500
+ ): string {
501
+ const secondField = schedule.pattern.second;
502
+ const shape = schedule.shapes.second;
462
503
 
463
504
  if (secondField === '*') {
464
505
  return 'joka sekunti';
@@ -466,13 +507,13 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
466
507
 
467
508
  if (shape === 'step') {
468
509
  // A step shape always has segments whose first is a step segment.
469
- return stepCycle60(stepSegment(ir.analyses.segments.second!),
510
+ return stepCycle60(stepSegment(schedule, 'second'),
470
511
  units.second, opts);
471
512
  }
472
513
 
473
514
  // The "joka minuutti" frequency mark is true only when the minute is open;
474
515
  // with a fixed minute the second fires within those minutes, not every one.
475
- const marked = ir.pattern.minute === '*';
516
+ const marked = schedule.pattern.minute === '*';
476
517
 
477
518
  if (shape === 'single') {
478
519
  return atMarks(secondField, units.second, marked);
@@ -480,53 +521,57 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
480
521
 
481
522
  // An offset/uneven step the core enumerated to this list reads as a stride
482
523
  // cadence when the fires form a long-enough progression.
483
- return strideFromSegments(ir.analyses.segments.second!, units.second, opts) ??
484
- atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
524
+ return strideFromSegments(
525
+ segmentsOf(schedule, 'second'), units.second, opts
526
+ ) ??
527
+ atMarks(joinList(segmentWords(segmentsOf(schedule, 'second'))),
485
528
  units.second, marked);
486
529
  }
487
530
 
488
531
  // --- Minute renderers. ---
489
532
 
490
533
  function renderEveryMinute(
491
- ir: IR,
534
+ schedule: Schedule,
492
535
  plan: Extract<PlanNode, {kind: 'everyMinute'}>,
493
536
  opts: NormalizedOptions
494
537
  ): string {
495
- return 'joka minuutti' + trailingQualifier(ir, opts);
538
+ return 'joka minuutti' + trailingQualifier(schedule, opts);
496
539
  }
497
540
 
498
541
  function renderSingleMinute(
499
- ir: IR,
542
+ schedule: Schedule,
500
543
  plan: Extract<PlanNode, {kind: 'singleMinute'}>,
501
544
  opts: NormalizedOptions
502
545
  ): string {
503
- return atMarks(ir.pattern.minute, units.minute, true) +
504
- trailingQualifier(ir, opts);
546
+ return atMarks(schedule.pattern.minute, units.minute, true) +
547
+ trailingQualifier(schedule, opts);
505
548
  }
506
549
 
507
550
  function renderRangeOfMinutes(
508
- ir: IR,
551
+ schedule: Schedule,
509
552
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
510
553
  opts: NormalizedOptions
511
554
  ): string {
512
- return minutesList(ir, opts) + trailingQualifier(ir, opts);
555
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
513
556
  }
514
557
 
515
558
  function renderMultipleMinutes(
516
- ir: IR,
559
+ schedule: Schedule,
517
560
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
518
561
  opts: NormalizedOptions
519
562
  ): string {
520
- return minutesList(ir, opts) + trailingQualifier(ir, opts);
563
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
521
564
  }
522
565
 
523
566
  // "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range). An offset/
524
567
  // uneven step the core enumerated to this list reads as a stride cadence when
525
568
  // the fires form a long-enough progression ("kahden minuutin välein
526
569
  // minuutista 3 minuuttiin 59").
527
- function minutesList(ir: IR, opts: NormalizedOptions): string {
528
- return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
529
- atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
570
+ function minutesList(schedule: Schedule, opts: NormalizedOptions): string {
571
+ return strideFromSegments(
572
+ segmentsOf(schedule, 'minute'), units.minute, opts
573
+ ) ??
574
+ atMarks(joinList(segmentWords(segmentsOf(schedule, 'minute'))),
530
575
  units.minute, true);
531
576
  }
532
577
 
@@ -534,9 +579,11 @@ function minutesList(ir: IR, opts: NormalizedOptions): string {
534
579
  // the "joka tunti" frequency would be redundant: "0–30 minuutin
535
580
  // kohdalla". A progression reads as its bounded cadence (which carries no
536
581
  // per-hour frequency to drop).
537
- function bareMinutes(ir: IR, opts: NormalizedOptions): string {
538
- return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
539
- atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
582
+ function bareMinutes(schedule: Schedule, opts: NormalizedOptions): string {
583
+ return strideFromSegments(
584
+ segmentsOf(schedule, 'minute'), units.minute, opts
585
+ ) ??
586
+ atMarks(joinList(segmentWords(segmentsOf(schedule, 'minute'))),
540
587
  units.minute, false);
541
588
  }
542
589
 
@@ -589,35 +636,35 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
589
636
  // hours-first.
590
637
  function hoursFirstMinutes(
591
638
  hoursStr: string,
592
- ir: IR,
639
+ schedule: Schedule,
593
640
  opts: NormalizedOptions
594
641
  ): string {
595
642
  // An offset/uneven step the core enumerated to this list reads as a stride
596
643
  // cadence ("aina kahden minuutin välein minuutista 3 minuuttiin 59") when
597
644
  // the fires form a long-enough progression, rather than the kohdalla list.
598
645
  const stride =
599
- strideFromSegments(ir.analyses.segments.minute!, units.minute, opts);
646
+ strideFromSegments(segmentsOf(schedule, 'minute'), units.minute, opts);
600
647
 
601
648
  if (stride) {
602
649
  return hoursStr + ' aina ' + stride;
603
650
  }
604
651
 
605
652
  return hoursStr + ' aina minuuttien ' +
606
- joinList(segmentWords(ir.analyses.segments.minute!)) + ' kohdalla';
653
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' kohdalla';
607
654
  }
608
655
 
609
656
  // Hour segment times for a range+isolated pattern: joins the isolated hour
610
657
  // with "sekä klo" rather than "ja", marking it as discrete rather than a
611
658
  // range extension. Used in bare-hour context only.
612
659
  function hourSegmentTimesWithSeka(
613
- ir: IR,
660
+ schedule: Schedule,
614
661
  minute: number,
615
662
  second: number | null | undefined,
616
663
  opts: NormalizedOptions
617
664
  ): string {
618
665
  const pieces: string[] = [];
619
666
 
620
- ir.analyses.segments.hour!.forEach(function clock(segment: Segment) {
667
+ segmentsOf(schedule, 'hour').forEach(function clock(segment: Segment) {
621
668
  if (segment.kind === 'range') {
622
669
  pieces.push(rangeDigits(
623
670
  {hour: +segment.bounds[0], minute, second},
@@ -634,36 +681,36 @@ function hourSegmentTimesWithSeka(
634
681
 
635
682
  // A repeating minute step, qualified by the active hour window(s).
636
683
  function renderMinuteFrequency(
637
- ir: IR,
684
+ schedule: Schedule,
638
685
  plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
639
686
  opts: NormalizedOptions
640
687
  ): string {
641
- const seg = stepSegment(ir.analyses.segments.minute!);
688
+ const seg = stepSegment(schedule, 'minute');
642
689
 
643
690
  if (plan.hours.kind === 'during') {
644
691
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence
645
692
  // after the minute step ("15 minuutin välein, viiden tunnin välein klo
646
693
  // 0–20").
647
- const cadence = unevenHourCadence(ir, opts);
694
+ const cadence = unevenHourCadence(schedule, opts);
648
695
 
649
696
  if (cadence !== null) {
650
697
  return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
651
- trailingQualifier(ir, opts);
698
+ trailingQualifier(schedule, opts);
652
699
  }
653
700
 
654
701
  // When the step renders as anchored ("kohdalla"), the per-hour windows
655
702
  // are redundant — use bare clock hours instead, then reorder to
656
703
  // hours-first: "klo <hours> aina minuuttien <spec> kohdalla".
657
704
  if (minuteStepIsAnchored(seg)) {
658
- const bareHours = kloFromTimes(ir, plan.hours.times, opts);
705
+ const bareHours = kloFromTimes(schedule, plan.hours.times, opts);
659
706
 
660
- return hoursFirstMinutes(bareHours, ir, opts) +
661
- trailingQualifier(ir, opts);
707
+ return hoursFirstMinutes(bareHours, schedule, opts) +
708
+ trailingQualifier(schedule, opts);
662
709
  }
663
710
 
664
711
  return stepCycle60(seg, units.minute, opts) + ' ' +
665
- hourWindowsFromTimes(ir, plan.hours.times, opts) +
666
- trailingQualifier(ir, opts);
712
+ hourWindowsFromTimes(schedule, plan.hours.times, opts) +
713
+ trailingQualifier(schedule, opts);
667
714
  }
668
715
 
669
716
  let phrase = stepCycle60(seg, units.minute, opts);
@@ -673,10 +720,10 @@ function renderMinuteFrequency(
673
720
  }
674
721
  else if (plan.hours.kind === 'step') {
675
722
  phrase += ' ' +
676
- everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
723
+ everyNthHour(stepSegment(schedule, 'hour'), opts);
677
724
  }
678
725
 
679
- return phrase + trailingQualifier(ir, opts);
726
+ return phrase + trailingQualifier(schedule, opts);
680
727
  }
681
728
 
682
729
  // "joka minuutti klo 9.00–9.59". A wildcard minute is the whole hour, so it
@@ -684,74 +731,76 @@ function renderMinuteFrequency(
684
731
  // synthesized "klo 9.00–9.59" range the source never stated; a plain range is
685
732
  // a real window and keeps the dash form.
686
733
  function renderMinuteSpanInHour(
687
- ir: IR,
734
+ schedule: Schedule,
688
735
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
689
736
  opts: NormalizedOptions
690
737
  ): string {
691
- if (ir.pattern.minute === '*') {
738
+ if (schedule.pattern.minute === '*') {
692
739
  return 'joka minuutti kello ' + plan.hour + ' aikana' +
693
- trailingQualifier(ir, opts);
740
+ trailingQualifier(schedule, opts);
694
741
  }
695
742
 
696
743
  return 'joka minuutti ' +
697
744
  kloRange({hour: plan.hour, minute: plan.span[0]},
698
745
  {hour: plan.hour, minute: plan.span[1]}, opts) +
699
- trailingQualifier(ir, opts);
746
+ trailingQualifier(schedule, opts);
700
747
  }
701
748
 
702
749
  // A minute window under discrete hours. Like Spanish, the wildcard form
703
- // re-strategizes to per-hour windows; restricted minutes drop the
750
+ // re-plans to per-hour windows; restricted minutes drop the
704
751
  // "jokaisen tunnin" anchor, which the specific hours would contradict.
705
752
  // A range or multi-point list over enumerated hours renders hours-first
706
753
  // ("klo <hours> aina minuuttien <spec> kohdalla"); a range+isolated hour
707
754
  // compound instead keeps minute-first and joins the isolated hour with
708
755
  // "sekä klo" (mirrors renderCompactClockTimes).
709
756
  function renderMinutesAcrossHours(
710
- ir: IR,
757
+ schedule: Schedule,
711
758
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
712
759
  opts: NormalizedOptions
713
760
  ): string {
714
761
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
715
762
  // the minute clause ("joka minuutti, viiden tunnin välein klo 0–20"), not a
716
763
  // wall of hour windows.
717
- const cadence = unevenHourCadence(ir, opts);
764
+ const cadence = unevenHourCadence(schedule, opts);
718
765
 
719
766
  if (plan.form === 'wildcard') {
720
767
  return cadence ?
721
- 'joka minuutti, ' + cadence + trailingQualifier(ir, opts) :
722
- 'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
723
- trailingQualifier(ir, opts);
768
+ 'joka minuutti, ' + cadence + trailingQualifier(schedule, opts) :
769
+ 'joka minuutti ' + hourWindowsFromTimes(schedule, plan.times, opts) +
770
+ trailingQualifier(schedule, opts);
724
771
  }
725
772
 
726
773
  if (cadence !== null) {
727
- return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
774
+ return bareMinutes(schedule, opts) + ', ' + cadence +
775
+ trailingQualifier(schedule, opts);
728
776
  }
729
777
 
730
778
  // Range+isolated hours: minute-first, bare minutes, sekä klo.
731
- if (hoursAreRangeIsolated(ir.analyses.segments.hour!)) {
732
- return bareMinutes(ir, opts) + ' ' +
733
- hourSegmentTimesWithSeka(ir, 0, null, opts) +
734
- trailingQualifier(ir, opts);
779
+ if (hoursAreRangeIsolated(segmentsOf(schedule, 'hour'))) {
780
+ return bareMinutes(schedule, opts) + ' ' +
781
+ hourSegmentTimesWithSeka(schedule, 0, null, opts) +
782
+ trailingQualifier(schedule, opts);
735
783
  }
736
784
 
737
785
  // Range or multi-value list (≥2 points) over enumerated hours →
738
786
  // hours-first. A single anchored minute stays minute-first (clock already
739
787
  // shows it).
740
- const hoursStr = kloFromTimes(ir, plan.times, opts);
788
+ const hoursStr = kloFromTimes(schedule, plan.times, opts);
741
789
 
742
- return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
790
+ return hoursFirstMinutes(hoursStr, schedule, opts) +
791
+ trailingQualifier(schedule, opts);
743
792
  }
744
793
 
745
794
  function renderMinuteSpanAcrossHourStep(
746
- ir: IR,
795
+ schedule: Schedule,
747
796
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
748
797
  opts: NormalizedOptions
749
798
  ): string {
750
799
  // An hour-step plan's first hour segment is always a step segment.
751
- const segment = stepSegment(ir.analyses.segments.hour!);
800
+ const segment = stepSegment(schedule, 'hour');
752
801
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
753
802
  // offset-clean stride keeps its confinement / per-step phrasing.
754
- const cadence = unevenHourCadence(ir, opts);
803
+ const cadence = unevenHourCadence(schedule, opts);
755
804
 
756
805
  // A wildcard span always sets the step off with a comma ("joka
757
806
  // minuutti, joka toinen tunti"); a restricted span joins a plain step
@@ -761,17 +810,18 @@ function renderMinuteSpanAcrossHourStep(
761
810
  // to every Nth hour; a restricted span is a per-hour window + plain step.
762
811
  if (plan.form === 'wildcard') {
763
812
  return 'joka minuutti ' + everyNthHour(segment, opts) +
764
- trailingQualifier(ir, opts);
813
+ trailingQualifier(schedule, opts);
765
814
  }
766
815
 
767
816
  // A bounded or uneven stride reads as its bounded cadence after the bare
768
817
  // minutes ("minuuteilla 0–30, kahden tunnin välein klo 9–17").
769
818
  if (cadence !== null) {
770
- return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
819
+ return bareMinutes(schedule, opts) + ', ' + cadence +
820
+ trailingQualifier(schedule, opts);
771
821
  }
772
822
 
773
- return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
774
- trailingQualifier(ir, opts);
823
+ return bareMinutes(schedule, opts) + hourStepTail(segment, opts) +
824
+ trailingQualifier(schedule, opts);
775
825
  }
776
826
 
777
827
  // Whether an hour step reads as the plain "joka toinen tunti" form: a
@@ -827,65 +877,67 @@ function hourStepTail(segment: StepSegment, opts: NormalizedOptions): string {
827
877
  // --- Hour renderers. ---
828
878
 
829
879
  function renderEveryHour(
830
- ir: IR,
880
+ schedule: Schedule,
831
881
  plan: Extract<PlanNode, {kind: 'everyHour'}>,
832
882
  opts: NormalizedOptions
833
883
  ): string {
834
- return 'joka tunti' + trailingQualifier(ir, opts);
884
+ return 'joka tunti' + trailingQualifier(schedule, opts);
835
885
  }
836
886
 
837
887
  function renderHourRange(
838
- ir: IR,
888
+ schedule: Schedule,
839
889
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
840
890
  opts: NormalizedOptions
841
891
  ): string {
842
892
  const window = hourWindow(boundedWindow(plan), opts);
843
893
 
844
894
  if (plan.minuteForm === 'wildcard') {
845
- return 'joka minuutti ' + window + trailingQualifier(ir, opts);
895
+ return 'joka minuutti ' + window + trailingQualifier(schedule, opts);
846
896
  }
847
897
 
848
898
  // A minute range over a single hour range renders hours-first
849
899
  // ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
850
900
  if (plan.minuteForm === 'range') {
851
- return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
901
+ return hoursFirstMinutes(window, schedule, opts) +
902
+ trailingQualifier(schedule, opts);
852
903
  }
853
904
 
854
905
  // On the hour the window joins directly ("joka tunti klo 9–17"); a
855
906
  // discrete minute anchors its own clause first.
856
- if (ir.pattern.minute === '0') {
857
- return 'joka tunti ' + window + trailingQualifier(ir, opts);
907
+ if (schedule.pattern.minute === '0') {
908
+ return 'joka tunti ' + window + trailingQualifier(schedule, opts);
858
909
  }
859
910
 
860
911
  // A single minute makes both window ends exact fires ("klo 9.30–17.30").
861
- if (ir.shapes.minute === 'single') {
862
- return atMarks(ir.pattern.minute, units.minute, false) + ' ' +
863
- kloRange({hour: plan.from, minute: +ir.pattern.minute},
912
+ if (schedule.shapes.minute === 'single') {
913
+ return atMarks(schedule.pattern.minute, units.minute, false) + ' ' +
914
+ kloRange({hour: plan.from, minute: +schedule.pattern.minute},
864
915
  {hour: plan.to, minute: plan.last}, opts) +
865
- trailingQualifier(ir, opts);
916
+ trailingQualifier(schedule, opts);
866
917
  }
867
918
 
868
919
  // A minute list (≥2 values) over a single hour range renders hours-first
869
920
  // ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
870
- return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
921
+ return hoursFirstMinutes(window, schedule, opts) +
922
+ trailingQualifier(schedule, opts);
871
923
  }
872
924
 
873
925
  function renderHourStep(
874
- ir: IR,
926
+ schedule: Schedule,
875
927
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
876
928
  opts: NormalizedOptions
877
929
  ): string {
878
930
  // A bounded or uneven hour step reads as its endpoint-pinning cadence
879
931
  // ("kahden tunnin välein klo 9–17"); an offset-clean step keeps its bare or
880
932
  // "alkaen" cadence.
881
- const cadence = unevenHourCadence(ir, opts);
933
+ const cadence = unevenHourCadence(schedule, opts);
882
934
 
883
935
  if (cadence !== null) {
884
- return cadence + trailingQualifier(ir, opts);
936
+ return cadence + trailingQualifier(schedule, opts);
885
937
  }
886
938
 
887
- return stepHours(stepSegment(ir.analyses.segments.hour!), opts) +
888
- trailingQualifier(ir, opts);
939
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
940
+ trailingQualifier(schedule, opts);
889
941
  }
890
942
 
891
943
  // The hour-range plan as a window whose closing minute honors `boundMinute`:
@@ -907,17 +959,17 @@ function hourWindow(window: HourWindow, opts: NormalizedOptions): string {
907
959
 
908
960
  // "joka päivä klo 9.30 ja 17.30".
909
961
  function renderClockTimes(
910
- ir: IR,
962
+ schedule: Schedule,
911
963
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
912
964
  opts: NormalizedOptions
913
965
  ): string {
914
966
  // An hour step or range (or arithmetic-progression hour list) under a single
915
967
  // pinned minute reads as a cadence or window rather than a cross-product of
916
968
  // clock times.
917
- if (ir.shapes.minute === 'single') {
918
- const minute = +ir.pattern.minute;
919
- const cadence = hourCadence(ir, minute, opts) ??
920
- hourRangeCadence(ir, minute, opts);
969
+ if (schedule.shapes.minute === 'single') {
970
+ const minute = +schedule.pattern.minute;
971
+ const cadence = hourCadence(schedule, minute, opts) ??
972
+ hourRangeCadence(schedule, minute, opts);
921
973
 
922
974
  if (cadence !== null) {
923
975
  return cadence;
@@ -927,7 +979,7 @@ function renderClockTimes(
927
979
  if (plan.times.length === 1) {
928
980
  const time = plan.times[0];
929
981
 
930
- return leadingQualifier(ir, opts) +
982
+ return leadingQualifier(schedule, opts) +
931
983
  timeWord(time.hour, time.minute, time.second, opts);
932
984
  }
933
985
 
@@ -935,7 +987,7 @@ function renderClockTimes(
935
987
  return timeDigits(time.hour, time.minute, time.second, opts);
936
988
  });
937
989
 
938
- return leadingQualifier(ir, opts) + 'klo ' + joinList(digits);
990
+ return leadingQualifier(schedule, opts) + 'klo ' + joinList(digits);
939
991
  }
940
992
 
941
993
  // Compact form past the enumeration cap: a single minute folds into
@@ -943,7 +995,7 @@ function renderClockTimes(
943
995
  // A minute list over enumerated (non-range+isolated) hours renders
944
996
  // hours-first; a range+isolated hour pattern joins with "sekä klo".
945
997
  function renderCompactClockTimes(
946
- ir: IR,
998
+ schedule: Schedule,
947
999
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
948
1000
  opts: NormalizedOptions
949
1001
  ): string {
@@ -951,15 +1003,15 @@ function renderCompactClockTimes(
951
1003
  // minute reads as a cadence, not a wall of clock times. (Returns null for an
952
1004
  // irregular list or a range, which keep folding below.)
953
1005
  if (plan.fold) {
954
- const cadence = hourCadence(ir, plan.minute, opts) ??
955
- hourRangeCadence(ir, plan.minute, opts);
1006
+ const cadence = hourCadence(schedule, plan.minute, opts) ??
1007
+ hourRangeCadence(schedule, plan.minute, opts);
956
1008
 
957
1009
  if (cadence !== null) {
958
1010
  return cadence;
959
1011
  }
960
1012
  }
961
1013
 
962
- const hourSegs = ir.analyses.segments.hour!;
1014
+ const hourSegs = segmentsOf(schedule, 'hour');
963
1015
 
964
1016
  // Range+isolated hours: join the isolated hour with "sekä klo" to stop it
965
1017
  // reading as a range extension. For the folded path (single minute folded
@@ -967,38 +1019,43 @@ function renderCompactClockTimes(
967
1019
  // path use bare-minutes-first with a trailing qualifier.
968
1020
  if (hoursAreRangeIsolated(hourSegs)) {
969
1021
  if (plan.fold) {
970
- return leadingQualifier(ir, opts) +
971
- hourSegmentTimesWithSeka(ir, plan.minute,
972
- ir.analyses.clockSecond, opts);
1022
+ return leadingQualifier(schedule, opts) +
1023
+ hourSegmentTimesWithSeka(schedule, plan.minute,
1024
+ schedule.analyses.clockSecond, opts);
973
1025
  }
974
1026
 
975
- const phrase = bareMinutes(ir, opts) + ' ' +
976
- hourSegmentTimesWithSeka(ir, 0, null, opts) +
977
- trailingQualifier(ir, opts);
1027
+ const phrase = bareMinutes(schedule, opts) + ' ' +
1028
+ hourSegmentTimesWithSeka(schedule, 0, null, opts) +
1029
+ trailingQualifier(schedule, opts);
978
1030
 
979
- return ir.analyses.clockSecond ?
980
- secondsLeadClause(ir, opts) + ', ' + phrase :
1031
+ return schedule.analyses.clockSecond ?
1032
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
981
1033
  phrase;
982
1034
  }
983
1035
 
984
1036
  if (plan.fold) {
985
- return leadingQualifier(ir, opts) +
986
- hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1037
+ return leadingQualifier(schedule, opts) +
1038
+ hourSegmentTimes(
1039
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1040
+ );
987
1041
  }
988
1042
 
989
1043
  // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
990
1044
  // the bare minute clause ("minuuteilla 0, 25 ja 50, viiden tunnin välein klo
991
1045
  // 0–20"), not a wall of clock-time columns.
992
- const cadence = unevenHourCadence(ir, opts);
1046
+ const cadence = unevenHourCadence(schedule, opts);
993
1047
  const phrase = cadence ?
994
- bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
1048
+ bareMinutes(schedule, opts) + ', ' + cadence +
1049
+ trailingQualifier(schedule, opts) :
995
1050
  // A minute list over purely enumerated hours (step fires, all singles) —
996
1051
  // hours-first, drop "joka tunti".
997
- hoursFirstMinutes(hourSegmentTimes(ir, 0, null, opts), ir, opts) +
998
- trailingQualifier(ir, opts);
1052
+ hoursFirstMinutes(
1053
+ hourSegmentTimes(schedule, 0, null, opts), schedule, opts
1054
+ ) +
1055
+ trailingQualifier(schedule, opts);
999
1056
 
1000
- return ir.analyses.clockSecond ?
1001
- secondsLeadClause(ir, opts) + ', ' + phrase :
1057
+ return schedule.analyses.clockSecond ?
1058
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
1002
1059
  phrase;
1003
1060
  }
1004
1061
 
@@ -1050,25 +1107,21 @@ interface Stride {
1050
1107
  function renderStride(stride: Stride, opts: NormalizedOptions): string {
1051
1108
  const {interval, start, last, cycle, unit} = stride;
1052
1109
  const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
1053
- const tiles = cycle % interval === 0;
1054
1110
 
1055
- if (start === 0 && tiles) {
1056
- return cadence;
1057
- }
1058
-
1059
- if (start < interval && tiles) {
1060
- return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
1061
- ' alkaen';
1062
- }
1063
-
1064
- return cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last;
1111
+ return chooseStride({start, interval, cycle}, {
1112
+ bare: () => cadence,
1113
+ offset: () =>
1114
+ cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start + ' alkaen',
1115
+ bounded: () =>
1116
+ cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last
1117
+ });
1065
1118
  }
1066
1119
 
1067
1120
  // Speak a minute/second field's enumerated fires as a step cadence when they
1068
1121
  // form an arithmetic progression long enough to beat the list (the core
1069
- // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1070
- // the renderer recognizes the progression). Returns null for a non-progression
1071
- // or a too-short list, leaving the caller to enumerate.
1122
+ // enumerates an offset/uneven step to this fire list; the Schedule is
1123
+ // unchanged, so the renderer recognizes the progression). Returns null for a
1124
+ // non-progression or a too-short list, leaving the caller to enumerate.
1072
1125
  function strideFromSegments(
1073
1126
  segments: Segment[],
1074
1127
  unit: UnitForms,
@@ -1082,21 +1135,6 @@ function strideFromSegments(
1082
1135
  null;
1083
1136
  }
1084
1137
 
1085
- // The sorted numeric values a field's segments cover, or null if any segment
1086
- // is not a discrete single (a range or sub-step is not a plain fire list).
1087
- function singleValues(segments: Segment[]): number[] | null {
1088
- const values: number[] = [];
1089
-
1090
- for (const segment of segments) {
1091
- if (segment.kind !== 'single') {
1092
- return null;
1093
- }
1094
-
1095
- values.push(+segment.value);
1096
- }
1097
-
1098
- return values;
1099
- }
1100
1138
 
1101
1139
  // "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
1102
1140
  // "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen". A step shape
@@ -1167,74 +1205,25 @@ function hourStrideCadence(
1167
1205
  ): string {
1168
1206
  const {start, interval, last} = stride;
1169
1207
  const cadence = genitive(interval, opts) + ' tunnin välein';
1170
- const tiles = 24 % interval === 0;
1171
-
1172
- if (start === 0 && tiles) {
1173
- return cadence;
1174
- }
1175
-
1176
- if (start < interval && tiles) {
1177
- return cadence + ' klo ' + hourElatives[start] + ' alkaen';
1178
- }
1179
-
1180
- return cadence + ' ' +
1181
- kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
1182
- }
1183
1208
 
1184
- // An hour list's arithmetic progression, or null when its values are not a step
1185
- // the renderer should speak as a cadence. The core rewrites a uneven hour step
1186
- // (whose interval does not tile 24, e.g. `*/5` 0,5,10,15,20) to its literal
1187
- // fire list, indistinguishable in the IR from a hand-written list; the renderer
1188
- // recovers the cadence from the values. A progression starting at zero is a
1189
- // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
1190
- // a step when it is too long to be a deliberate clock-time list (9,17 is two
1191
- // named times, not a cadence). Interval one is a plain range, never a step.
1192
- function hourListStride(
1193
- values: number[]
1194
- ): {start: number; interval: number; last: number} | null {
1195
- if (values.length < 2) {
1196
- return null;
1197
- }
1198
-
1199
- const interval = values[1] - values[0];
1200
-
1201
- if (interval < 2) {
1202
- return null;
1203
- }
1204
-
1205
- for (let i = 2; i < values.length; i += 1) {
1206
- if (values[i] - values[i - 1] !== interval) {
1207
- return null;
1208
- }
1209
- }
1210
-
1211
- if (values[0] !== 0 && values.length < 5) {
1212
- return null;
1213
- }
1214
-
1215
- return {interval, last: values[values.length - 1], start: values[0]};
1216
- }
1217
-
1218
- // Whether an hour stride wraps the day cleanly from within its first interval
1219
- // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1220
- // stride has no distinct endpoint and keeps its bare or "alkaen" cadence. Every
1221
- // other stride — a uneven interval, or one starting at or past its interval (a
1222
- // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1223
- function offsetCleanStride(
1224
- stride: {start: number; interval: number}
1225
- ): boolean {
1226
- return stride.start < stride.interval && 24 % stride.interval === 0;
1209
+ return chooseStride({start, interval, cycle: 24}, {
1210
+ bare: () => cadence,
1211
+ offset: () => cadence + ' klo ' + hourElatives[start] + ' alkaen',
1212
+ bounded: () => cadence + ' ' +
1213
+ kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts)
1214
+ });
1227
1215
  }
1228
1216
 
1229
1217
  // The hour field's stride, or null when the hour is not a cadence: a step
1230
1218
  // segment yields its {start, interval, last} directly; an all-single hour list
1231
1219
  // yields one only when its values form a step progression (so an irregular list
1232
- // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1233
- // the stride and speaks it as a cadence, not the clock-time cross-product.
1220
+ // like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
1221
+ // recognizes the stride and speaks it as a cadence, not the clock-time
1222
+ // cross-product.
1234
1223
  function hourStride(
1235
- ir: IR
1224
+ schedule: Schedule
1236
1225
  ): {start: number; interval: number; last: number} | null {
1237
- const segments = ir.analyses.segments.hour;
1226
+ const segments = schedule.analyses.segments.hour;
1238
1227
 
1239
1228
  // A wildcard hour carries no segments (no discrete hours to stride over).
1240
1229
  if (!segments) {
@@ -1270,8 +1259,10 @@ function hourStride(
1270
1259
  // ("…, viiden tunnin välein klo 0–20") than as a wall of clock times. An
1271
1260
  // offset-clean stride keeps its existing confinement form, so only the
1272
1261
  // endpoint-bearing case routes here.
1273
- function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1274
- const stride = hourStride(ir);
1262
+ function unevenHourCadence(
1263
+ schedule: Schedule, opts: NormalizedOptions
1264
+ ): string | null {
1265
+ const stride = hourStride(schedule);
1275
1266
 
1276
1267
  if (!stride || offsetCleanStride(stride)) {
1277
1268
  return null;
@@ -1283,8 +1274,8 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1283
1274
  // The second's status against a pinned minute: a wildcard or sub-minute step
1284
1275
  // fills the minute (a "minuutin ajan" frame at minute 0); a single 0 is just
1285
1276
  // the top of the minute (no clause); anything else needs its own clause.
1286
- function subMinuteSecond(ir: IR): boolean {
1287
- return ir.pattern.second === '*' || ir.shapes.second === 'step';
1277
+ function subMinuteSecond(schedule: Schedule): boolean {
1278
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1288
1279
  }
1289
1280
 
1290
1281
  // The lead clause for an hour-cadence rendering: the second and the pinned
@@ -1294,25 +1285,25 @@ function subMinuteSecond(ir: IR): boolean {
1294
1285
  // ajan" frame (the whole minute-0 window). A non-zero minute is a real clock
1295
1286
  // minute: the second leads with its own clause (if any), then the minute reads
1296
1287
  // at its bare "kohdalla" mark.
1297
- function hourCadenceLead(ir: IR, minute: number,
1288
+ function hourCadenceLead(schedule: Schedule, minute: number,
1298
1289
  opts: NormalizedOptions): string {
1299
1290
  if (minute === 0) {
1300
- if (subMinuteSecond(ir)) {
1301
- return secondsLeadClause(ir, opts) + ' minuutin ajan';
1291
+ if (subMinuteSecond(schedule)) {
1292
+ return secondsLeadClause(schedule, opts) + ' minuutin ajan';
1302
1293
  }
1303
1294
 
1304
- return secondsLeadClause(ir, opts);
1295
+ return secondsLeadClause(schedule, opts);
1305
1296
  }
1306
1297
 
1307
1298
  const minutePhrase = atMarks(String(minute), units.minute, false);
1308
1299
 
1309
1300
  // A single 0 second is just the top of the minute, so the minute leads
1310
1301
  // alone; any other second prefixes its own clause.
1311
- if (ir.pattern.second === '0') {
1302
+ if (schedule.pattern.second === '0') {
1312
1303
  return minutePhrase;
1313
1304
  }
1314
1305
 
1315
- return secondsLeadClause(ir, opts) + ', ' + minutePhrase;
1306
+ return secondsLeadClause(schedule, opts) + ', ' + minutePhrase;
1316
1307
  }
1317
1308
 
1318
1309
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1323,10 +1314,10 @@ function hourCadenceLead(ir: IR, minute: number,
1323
1314
  // enumeration is no longer than the cadence: a meaningful second makes every
1324
1315
  // clock time three digit-groups, so any stride is worth compacting; otherwise
1325
1316
  // the stride must exceed the clock-time cap, the same point at which the core
1326
- // itself stops enumerating. Renderer-only; the IR is unchanged.
1327
- function hourCadence(ir: IR, minute: number,
1317
+ // itself stops enumerating. Renderer-only; the Schedule is unchanged.
1318
+ function hourCadence(schedule: Schedule, minute: number,
1328
1319
  opts: NormalizedOptions): string | null {
1329
- const stride = hourStride(ir);
1320
+ const stride = hourStride(schedule);
1330
1321
 
1331
1322
  if (!stride) {
1332
1323
  return null;
@@ -1339,7 +1330,7 @@ function hourCadence(ir: IR, minute: number,
1339
1330
  // or "alkaen" form is no shorter than the list. A bounded or uneven stride
1340
1331
  // has no clean wrap, so its endpoint-pinning cadence ("viiden tunnin välein
1341
1332
  // klo 0–20") reads better however short.
1342
- if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1333
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1343
1334
  offsetCleanStride(stride)) {
1344
1335
  return null;
1345
1336
  }
@@ -1348,25 +1339,25 @@ function hourCadence(ir: IR, minute: number,
1348
1339
  // stride is a confinement, not a juxtaposed cadence: it reads "minuutin ajan
1349
1340
  // joka toisen tunnin aikana", reusing the every-Nth-hour idiom so the
1350
1341
  // minute-0 window is never heard as the bare hour cadence.
1351
- const segment = ir.analyses.segments.hour![0];
1352
- const confined = minute === 0 && subMinuteSecond(ir) &&
1353
- ir.analyses.segments.hour!.length === 1 && segment.kind === 'step' &&
1342
+ const segment = segmentsOf(schedule, 'hour')[0];
1343
+ const confined = minute === 0 && subMinuteSecond(schedule) &&
1344
+ segmentsOf(schedule, 'hour').length === 1 && segment.kind === 'step' &&
1354
1345
  cleanHourStride(segment);
1355
1346
 
1356
1347
  if (confined) {
1357
- return secondsLeadClause(ir, opts) + ' minuutin ajan ' +
1358
- everyNthHour(segment, opts) + trailingQualifier(ir, opts);
1348
+ return secondsLeadClause(schedule, opts) + ' minuutin ajan ' +
1349
+ everyNthHour(segment, opts) + trailingQualifier(schedule, opts);
1359
1350
  }
1360
1351
 
1361
1352
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1362
1353
  // lead clause to fold in, so the bounded cadence stands on its own ("viiden
1363
1354
  // tunnin välein klo 0–20").
1364
- if (minute === 0 && ir.pattern.second === '0') {
1365
- return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1355
+ if (minute === 0 && schedule.pattern.second === '0') {
1356
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1366
1357
  }
1367
1358
 
1368
- return hourCadenceLead(ir, minute, opts) + ', ' +
1369
- hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1359
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1360
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1370
1361
  }
1371
1362
 
1372
1363
  // Whether an hour step is a clean stride over the whole day — unbounded,
@@ -1386,8 +1377,8 @@ function cleanHourStride(segment: StepSegment): boolean {
1386
1377
  // range — and so forms a window rather than a cross-product of clock times.
1387
1378
  // A pure single-value list (9,17) has no range to span and still enumerates;
1388
1379
  // a step is handled by hourStride/hourCadence.
1389
- function hasHourWindow(ir: IR): boolean {
1390
- const segments = ir.analyses.segments.hour;
1380
+ function hasHourWindow(schedule: Schedule): boolean {
1381
+ const segments = schedule.analyses.segments.hour;
1391
1382
 
1392
1383
  return !!segments && segments.some(function range(segment: Segment) {
1393
1384
  return segment.kind === 'range';
@@ -1399,10 +1390,12 @@ function hasHourWindow(ir: IR): boolean {
1399
1390
  // with "sekä klo" ("klo 9–20 sekä klo 22"), the same idiom the bare folded
1400
1391
  // window uses. The minute has folded into the lead, so the window closes on
1401
1392
  // the top of its final hour.
1402
- function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1403
- return ir.analyses.segments.hour!.length === 1 ?
1404
- hourSegmentTimes(ir, 0, null, opts) :
1405
- hourSegmentTimesWithSeka(ir, 0, null, opts);
1393
+ function hourRangeWindowTail(
1394
+ schedule: Schedule, opts: NormalizedOptions
1395
+ ): string {
1396
+ return segmentsOf(schedule, 'hour').length === 1 ?
1397
+ hourSegmentTimes(schedule, 0, null, opts) :
1398
+ hourSegmentTimesWithSeka(schedule, 0, null, opts);
1406
1399
  }
1407
1400
 
1408
1401
  // Render an hour range (or a list whose segments include a range) under
@@ -1411,24 +1404,25 @@ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1411
1404
  // clock times. The hour-RANGE analog of hourCadence. Returns null when the
1412
1405
  // hour has no range, when the minute is non-zero (a real clock minute the
1413
1406
  // existing window form already speaks), or when a plain :00 set carries no
1414
- // clause. Renderer-only; the IR is unchanged.
1415
- function hourRangeCadence(ir: IR, minute: number,
1407
+ // clause. Renderer-only; the Schedule is unchanged.
1408
+ function hourRangeCadence(schedule: Schedule, minute: number,
1416
1409
  opts: NormalizedOptions): string | null {
1417
- if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1410
+ if (minute !== 0 || !hasHourWindow(schedule) ||
1411
+ schedule.pattern.second === '0') {
1418
1412
  return null;
1419
1413
  }
1420
1414
 
1421
- const tail = hourRangeWindowTail(ir, opts);
1415
+ const tail = hourRangeWindowTail(schedule, opts);
1422
1416
 
1423
1417
  // A wildcard or sub-minute step second is the whole minute-0 window
1424
1418
  // ("minuutin ajan", carried by hourCadenceLead), then the window — kept
1425
1419
  // distinct from the bare "joka tunti klo 9–17" so the confinement is never
1426
1420
  // heard as it (the hour-range analog of "minuutin ajan joka toisen tunnin
1427
1421
  // aikana"). A meaningful second leads at its mark, then the window.
1428
- const joiner = subMinuteSecond(ir) ? ' ' : ', ';
1422
+ const joiner = subMinuteSecond(schedule) ? ' ' : ', ';
1429
1423
 
1430
- return hourCadenceLead(ir, minute, opts) + joiner + tail +
1431
- trailingQualifier(ir, opts);
1424
+ return hourCadenceLead(schedule, minute, opts) + joiner + tail +
1425
+ trailingQualifier(schedule, opts);
1432
1426
  }
1433
1427
 
1434
1428
  // --- Hour-time phrasing. ---
@@ -1447,7 +1441,7 @@ function kloList(hours: number[], opts: NormalizedOptions): string {
1447
1441
  // The hour times accompanying a lead clause, with long expansions
1448
1442
  // rendered segment by segment.
1449
1443
  function kloFromTimes(
1450
- ir: IR,
1444
+ schedule: Schedule,
1451
1445
  times: HourTimesPlan,
1452
1446
  opts: NormalizedOptions
1453
1447
  ): string {
@@ -1455,7 +1449,7 @@ function kloFromTimes(
1455
1449
  return kloList(times.fires, opts);
1456
1450
  }
1457
1451
 
1458
- return hourSegmentTimes(ir, 0, null, opts);
1452
+ return hourSegmentTimes(schedule, 0, null, opts);
1459
1453
  }
1460
1454
 
1461
1455
  // The hours accompanying a named-once minute clause under an hour list or
@@ -1465,7 +1459,7 @@ function kloFromTimes(
1465
1459
  // per-segment window ("klo 8.00–18.59 ja 22.00–22.59"), mirroring the other
1466
1460
  // languages, which list discrete hours but keep range windows.
1467
1461
  function hourWindowsFromTimes(
1468
- ir: IR,
1462
+ schedule: Schedule,
1469
1463
  times: HourTimesPlan,
1470
1464
  opts: NormalizedOptions
1471
1465
  ): string {
@@ -1473,7 +1467,7 @@ function hourWindowsFromTimes(
1473
1467
  return kloList(times.fires, opts);
1474
1468
  }
1475
1469
 
1476
- const segments = ir.analyses.segments.hour!;
1470
+ const segments = segmentsOf(schedule, 'hour');
1477
1471
 
1478
1472
  if (!segments.some(function ranged(segment: Segment) {
1479
1473
  return segment.kind === 'range';
@@ -1527,14 +1521,14 @@ function hourWindowDigits(hour: number, opts: NormalizedOptions): string {
1527
1521
  // klo, the minute (and optional second) folded into each:
1528
1522
  // "klo 9.30–20.30 ja 22.30".
1529
1523
  function hourSegmentTimes(
1530
- ir: IR,
1524
+ schedule: Schedule,
1531
1525
  minute: number,
1532
1526
  second: number | null | undefined,
1533
1527
  opts: NormalizedOptions
1534
1528
  ): string {
1535
1529
  const pieces: string[] = [];
1536
1530
 
1537
- ir.analyses.segments.hour!.forEach(function clock(segment: Segment) {
1531
+ segmentsOf(schedule, 'hour').forEach(function clock(segment: Segment) {
1538
1532
  if (segment.kind === 'step') {
1539
1533
  pieces.push(...segment.fires.map(function each(hour: number) {
1540
1534
  return timeDigits(hour, minute, second, opts);
@@ -1626,30 +1620,30 @@ function timeDigits(
1626
1620
 
1627
1621
  // The qualifier that precedes clock times: "joka päivä ",
1628
1622
  // "maanantaisin ", "kuukauden 13. päivänä ".
1629
- function leadingQualifier(ir: IR, opts: NormalizedOptions): string {
1630
- const pattern = ir.pattern;
1623
+ function leadingQualifier(schedule: Schedule, opts: NormalizedOptions): string {
1624
+ const pattern = schedule.pattern;
1631
1625
 
1632
1626
  // When a restricted-month union is active, describe() assembles the full
1633
1627
  // compound; suppress the qualifier here so render() returns only the
1634
1628
  // time/frequency part.
1635
- if (restrictedMonthUnion(ir)) {
1629
+ if (restrictedMonthUnion(schedule)) {
1636
1630
  return '';
1637
1631
  }
1638
1632
 
1639
1633
  if (pattern.date !== '*' && pattern.weekday !== '*') {
1640
- return dateOrWeekday(ir, opts) + ' ';
1634
+ return dateOrWeekday(schedule, opts) + ' ';
1641
1635
  }
1642
1636
 
1643
1637
  if (pattern.date !== '*') {
1644
- return datePhrase(ir, opts) + ' ';
1638
+ return datePhrase(schedule, opts) + ' ';
1645
1639
  }
1646
1640
 
1647
1641
  if (pattern.weekday !== '*') {
1648
- return weekdayQualifier(ir) + monthScope(ir) + ' ';
1642
+ return weekdayQualifier(schedule) + monthScope(schedule) + ' ';
1649
1643
  }
1650
1644
 
1651
1645
  if (pattern.month !== '*') {
1652
- return 'joka päivä ' + monthPhrase(ir) + ' ';
1646
+ return 'joka päivä ' + monthPhrase(schedule) + ' ';
1653
1647
  }
1654
1648
 
1655
1649
  return 'joka päivä ';
@@ -1657,57 +1651,64 @@ function leadingQualifier(ir: IR, opts: NormalizedOptions): string {
1657
1651
 
1658
1652
  // The qualifier trailing a frequency: " maanantaisin", " kesäkuussa",
1659
1653
  // " kuukauden 13. päivänä". Empty when no day-level field is set.
1660
- function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1661
- const pattern = ir.pattern;
1654
+ function trailingQualifier(
1655
+ schedule: Schedule, opts: NormalizedOptions
1656
+ ): string {
1657
+ const pattern = schedule.pattern;
1662
1658
 
1663
1659
  // When a restricted-month union is active, describe() assembles the full
1664
1660
  // compound; suppress the qualifier here so render() returns only the
1665
1661
  // time/frequency part.
1666
- if (restrictedMonthUnion(ir)) {
1662
+ if (restrictedMonthUnion(schedule)) {
1667
1663
  return '';
1668
1664
  }
1669
1665
 
1670
1666
  if (pattern.date !== '*' && pattern.weekday !== '*') {
1671
- return ' ' + dateOrWeekday(ir, opts);
1667
+ return ' ' + dateOrWeekday(schedule, opts);
1672
1668
  }
1673
1669
 
1674
1670
  if (pattern.date !== '*') {
1675
- return ' ' + datePhrase(ir, opts);
1671
+ return ' ' + datePhrase(schedule, opts);
1676
1672
  }
1677
1673
 
1678
1674
  if (pattern.weekday !== '*') {
1679
- return ' ' + weekdayQualifier(ir) + monthScope(ir);
1675
+ return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
1680
1676
  }
1681
1677
 
1682
1678
  if (pattern.month !== '*') {
1683
- return ' ' + monthPhrase(ir);
1679
+ return ' ' + monthPhrase(schedule);
1684
1680
  }
1685
1681
 
1686
1682
  return '';
1687
1683
  }
1688
1684
 
1689
1685
  // "kuukauden 13. päivänä tai perjantaisin": cron fires when either the
1690
- // date or the weekday matches. Only reachable when date≠* AND weekday≠*
1691
- // AND month=* (the restricted-month union is handled in describe()),
1692
- // so monthScope always returns '' here.
1693
- function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
1694
- return datePhrase(ir, opts) + ' tai ' + weekdayQualifier(ir) +
1695
- monthScope(ir);
1686
+ // date or the weekday matches (inclusive union). Only reachable when date≠*
1687
+ // AND weekday≠* AND month=* (the restricted-month union is handled in
1688
+ // describe()), so monthScope always returns '' here. An open `*/2` date
1689
+ // reads as the odd-day parity class (not the continuous "joka toinen
1690
+ // päivä"); a Mon–Fri weekday reads as the recurring class "arkisin".
1691
+ function dateOrWeekday(schedule: Schedule, opts: NormalizedOptions): string {
1692
+ const dateArm = oddDayUnion(schedule.pattern.date) ||
1693
+ datePhrase(schedule, opts);
1694
+
1695
+ return dateArm + ' tai ' + unionWeekdayArm(schedule) +
1696
+ monthScope(schedule);
1696
1697
  }
1697
1698
 
1698
1699
  // The weekday qualifier: distributive lists ("maanantaisin,
1699
1700
  // keskiviikkoisin ja perjantaisin") and elative–illative ranges
1700
1701
  // ("maanantaista perjantaihin"). Step segments flatten into their fires.
1701
- function weekdayQualifier(ir: IR): string {
1702
- const quartz = quartzWeekdayPhrase(ir.pattern.weekday);
1702
+ function weekdayQualifier(schedule: Schedule): string {
1703
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
1703
1704
 
1704
1705
  if (quartz) {
1705
1706
  return quartz;
1706
1707
  }
1707
1708
 
1708
1709
  // Weekday lists display Monday-first (Sunday last); a lone range keeps its
1709
- // form. The IR stays canonical (Sunday=0). The helper flattens steps.
1710
- const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
1710
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
1711
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
1711
1712
 
1712
1713
  return joinList(segments.map(function piece(segment: FlatSegment) {
1713
1714
  if (segment.kind === 'range') {
@@ -1722,8 +1723,8 @@ function weekdayQualifier(ir: IR): string {
1722
1723
  // The month qualifier: inessive names ("kesäkuussa ja joulukuussa") and
1723
1724
  // elative–illative ranges ("kesäkuusta syyskuuhun"). The case endings
1724
1725
  // keep mixed lists unambiguous with no preposition bookkeeping.
1725
- function monthPhrase(ir: IR): string {
1726
- const segments = flattenSteps(ir.analyses.segments.month!);
1726
+ function monthPhrase(schedule: Schedule): string {
1727
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
1727
1728
 
1728
1729
  return joinList(segments.map(function piece(segment: FlatSegment) {
1729
1730
  if (segment.kind === 'range') {
@@ -1737,12 +1738,12 @@ function monthPhrase(ir: IR): string {
1737
1738
 
1738
1739
  // A trailing month scope on weekday qualifiers ("maanantaisin
1739
1740
  // kesäkuussa").
1740
- function monthScope(ir: IR): string {
1741
- if (ir.pattern.month === '*') {
1741
+ function monthScope(schedule: Schedule): string {
1742
+ if (schedule.pattern.month === '*') {
1742
1743
  return '';
1743
1744
  }
1744
1745
 
1745
- return ' ' + monthPhrase(ir);
1746
+ return ' ' + monthPhrase(schedule);
1746
1747
  }
1747
1748
 
1748
1749
  // Expand step segments into their fires as singles: the flat fires read
@@ -1760,20 +1761,21 @@ function flattenSteps(segments: Segment[]): FlatSegment[] {
1760
1761
  // The date qualifier: "kuukauden 13. päivänä", "tammikuun 1. päivänä",
1761
1762
  // "joka kolmannen kuukauden 1. päivänä", or a Quartz phrase. A foldable
1762
1763
  // single year joins the date ("joulukuun 25. päivänä vuonna 2030").
1763
- function datePhrase(ir: IR, opts: NormalizedOptions): string {
1764
- const pattern = ir.pattern;
1764
+ function datePhrase(schedule: Schedule, opts: NormalizedOptions): string {
1765
+ const pattern = schedule.pattern;
1765
1766
  const quartz = quartzDatePhrase(pattern.date);
1766
1767
 
1767
1768
  if (quartz) {
1768
- return quartz + monthScope(ir);
1769
+ return quartz + monthScope(schedule);
1769
1770
  }
1770
1771
 
1771
1772
  if (isOpenStep(pattern.date)) {
1772
- return stepDates(pattern.date, opts) + monthScope(ir);
1773
+ return stepDates(pattern.date, opts) + monthScope(schedule);
1773
1774
  }
1774
1775
 
1775
- return monthAnchor(ir, opts) + ' ' + dateWords(ir) + ' päivänä' +
1776
- foldedYear(ir) + monthStepStart(pattern.month) + rangedMonthScope(ir);
1776
+ return monthAnchor(schedule, opts) + ' ' + dateWords(schedule) + ' päivänä' +
1777
+ foldedYear(schedule) + monthStepStart(pattern.month) +
1778
+ rangedMonthScope(schedule);
1777
1779
  }
1778
1780
 
1779
1781
  // " helmikuusta alkaen" trailing the date words when an open month step
@@ -1797,10 +1799,10 @@ function monthStepStart(monthField: string): string {
1797
1799
  // "tammikuun", "kesäkuun ja joulukuun", or "joka kolmannen kuukauden". A
1798
1800
  // ranged month cannot take the genitive, so it scopes the date from
1799
1801
  // behind instead (rangedMonthScope).
1800
- function monthAnchor(ir: IR, opts: NormalizedOptions): string {
1801
- const monthField = ir.pattern.month;
1802
+ function monthAnchor(schedule: Schedule, opts: NormalizedOptions): string {
1803
+ const monthField = schedule.pattern.month;
1802
1804
 
1803
- if (monthField === '*' || monthRanged(ir)) {
1805
+ if (monthField === '*' || monthRanged(schedule)) {
1804
1806
  return 'kuukauden';
1805
1807
  }
1806
1808
 
@@ -1808,7 +1810,7 @@ function monthAnchor(ir: IR, opts: NormalizedOptions): string {
1808
1810
  return stepMonths(monthField, opts);
1809
1811
  }
1810
1812
 
1811
- const segments = flattenSteps(ir.analyses.segments.month!);
1813
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
1812
1814
 
1813
1815
  return joinList(segments.map(function genitiveOf(segment: FlatSegment) {
1814
1816
  // The anchor branch is only reached for non-ranged months, so every
@@ -1820,22 +1822,22 @@ function monthAnchor(ir: IR, opts: NormalizedOptions): string {
1820
1822
  }
1821
1823
 
1822
1824
  // " kesäkuusta syyskuuhun" trailing a date under a ranged month.
1823
- function rangedMonthScope(ir: IR): string {
1824
- return monthRanged(ir) ? ' ' + monthPhrase(ir) : '';
1825
+ function rangedMonthScope(schedule: Schedule): string {
1826
+ return monthRanged(schedule) ? ' ' + monthPhrase(schedule) : '';
1825
1827
  }
1826
1828
 
1827
1829
  // Whether the month field contains a range segment.
1828
- function monthRanged(ir: IR): boolean {
1829
- return ir.pattern.month !== '*' &&
1830
- ir.analyses.segments.month!.some(function range(segment: Segment) {
1830
+ function monthRanged(schedule: Schedule): boolean {
1831
+ return schedule.pattern.month !== '*' &&
1832
+ segmentsOf(schedule, 'month').some(function range(segment: Segment) {
1831
1833
  return segment.kind === 'range';
1832
1834
  });
1833
1835
  }
1834
1836
 
1835
1837
  // The day-of-month words: "13.", "1. ja 15.", "1.–15.", with step
1836
1838
  // segments expanded into their fires.
1837
- function dateWords(ir: IR): string {
1838
- return joinList(ir.analyses.segments.date!.flatMap(
1839
+ function dateWords(schedule: Schedule): string {
1840
+ return joinList(segmentsOf(schedule, 'date').flatMap(
1839
1841
  function word(segment: Segment): string[] {
1840
1842
  if (segment.kind === 'range') {
1841
1843
  return [segment.bounds[0] + '.–' + segment.bounds[1] + '.'];
@@ -1932,10 +1934,10 @@ function monthNumber(token: string | number): number {
1932
1934
  // rendered.
1933
1935
  function applyYear(
1934
1936
  description: string,
1935
- ir: IR,
1937
+ schedule: Schedule,
1936
1938
  opts: NormalizedOptions
1937
1939
  ): string {
1938
- const yearField = ir.pattern.year;
1940
+ const yearField = schedule.pattern.year;
1939
1941
 
1940
1942
  if (yearField === '*') {
1941
1943
  return description;
@@ -1946,7 +1948,7 @@ function applyYear(
1946
1948
  }
1947
1949
 
1948
1950
  // A foldable single year already joined its date in datePhrase.
1949
- if (foldedYear(ir) && ir.pattern.date !== '*') {
1951
+ if (foldedYear(schedule) && schedule.pattern.date !== '*') {
1950
1952
  return description;
1951
1953
  }
1952
1954
 
@@ -1979,8 +1981,8 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1979
1981
  }
1980
1982
 
1981
1983
  // " vuonna 2030" when a single year can fold into a calendar date.
1982
- function foldedYear(ir: IR): string {
1983
- const yearField = ir.pattern.year;
1984
+ function foldedYear(schedule: Schedule): string {
1985
+ const yearField = schedule.pattern.year;
1984
1986
 
1985
1987
  if (yearField === '*' || yearField.indexOf('/') !== -1 ||
1986
1988
  yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
@@ -2008,13 +2010,6 @@ function segmentWords(segments: Segment[]): string[] {
2008
2010
  });
2009
2011
  }
2010
2012
 
2011
- // Whether a canonical field value is an "open" step (`*/n` or `a/n`, not
2012
- // a bounded range or a list). Open steps read as a frequency rather than
2013
- // an enumeration.
2014
- function isOpenStep(field: string): boolean {
2015
- return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
2016
- field.indexOf(',') === -1;
2017
- }
2018
2013
 
2019
2014
  // Numeric fire values as digits.
2020
2015
  function wordList(fires: number[]): string[] {
@@ -2058,7 +2053,7 @@ function joinList(items: string[]): string {
2058
2053
  return items.slice(0, -1).join(', ') + ' ja ' + items[items.length - 1];
2059
2054
  }
2060
2055
 
2061
- // The Finnish language module: the IR renderer plus the language-owned
2056
+ // The Finnish language module: the Schedule renderer plus the language-owned
2062
2057
  // strings and option normalization.
2063
2058
  const fi: Language = {
2064
2059
  describe,