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