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,19 +1,20 @@
1
- // The English language module: renders an analyzed cron pattern (the IR
1
+ // The English language module: renders an analyzed cron pattern (the Schedule
2
2
  // produced by core `analyze`) as idiomatic English. All words live here;
3
- // the core stays semantic, and this module's only input is the IR.
3
+ // the core stays semantic, and this module's only input is the Schedule.
4
4
  // See docs/i18n-design.md.
5
5
 
6
6
  import {
7
- arithmeticStep, hourListStride, offsetCleanStride, orderWeekdaysForDisplay,
8
- segmentsOf, singleValues, stepSegment
9
- } from '../../core/util.js';
7
+ arithmeticStep, hourListStride, offsetCleanStride,
8
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
9
+ } from '../../core/cadence.js';
10
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
10
11
  import {isOpenStep} from '../../core/shapes.js';
11
12
  import {maxClockTimes} from '../../core/specs.js';
12
13
  import {clockDigits, numeral, pad} from '../../core/format.js';
13
14
  import type {Cronli5Options} from '../../types.js';
14
15
  import type {
15
- HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
16
- } from '../../core/ir.js';
16
+ HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode, Segment
17
+ } from '../../core/schedule.js';
17
18
  import {resolveDialect} from './dialects.js';
18
19
 
19
20
  // The plan node of a given kind: the discriminated-union member a renderer
@@ -143,91 +144,222 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
143
144
  };
144
145
  }
145
146
 
146
- // Render an analyzed cron pattern (the IR) as English.
147
- function describe(ir: IR, opts: NormalizedOptions): string {
147
+ // Render an analyzed cron pattern (the Schedule) as English.
148
+ function describe(schedule: Schedule, opts: NormalizedOptions): string {
149
+ // A dense pattern — a seconds cadence stacked on a minutes cadence under an
150
+ // hours cadence — reads coarse-to-fine with the second nested under the
151
+ // minute, leading with the calendar anchor; it preempts the fine-to-coarse
152
+ // run-on the per-plan composer would otherwise produce.
153
+ const dense = denseCadence(schedule, opts);
154
+
155
+ if (dense !== null) {
156
+ return applyYear(dense, schedule, opts);
157
+ }
158
+
148
159
  // A finer leading cadence puts each coarser field in the confinement frame,
149
160
  // overriding the per-plan juxtaposed-cadence and duration-frame forms.
150
- const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
161
+ const body = confinement(schedule, opts) ??
162
+ render(schedule, schedule.plan, opts);
151
163
 
152
164
  // A day union scopes the whole clause by its month, which leads the
153
165
  // description ("in June <time> whenever the day is …"); the time/cadence and
154
166
  // the trailing condition are already in `body`.
155
- const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : '';
167
+ const lead = isDayUnion(schedule, opts) ?
168
+ dayUnionMonthLead(schedule, opts) : '';
156
169
 
157
- return applyYear(lead + body, ir, opts);
170
+ return applyYear(lead + body, schedule, opts);
158
171
  }
159
172
 
160
173
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
161
- function render(ir: IR, plan: PlanNode, opts: NormalizedOptions): string {
174
+ function render(schedule: Schedule, plan: PlanNode,
175
+ opts: NormalizedOptions): string {
162
176
  // The dispatch table keys each renderer to its own plan kind; the lookup
163
177
  // by `plan.kind` cannot prove the node matches the renderer's narrowed
164
178
  // parameter, so the call is made through a kind-agnostic signature.
165
179
  const renderer = renderers[plan.kind] as
166
- (ir: IR, plan: PlanNode, opts: NormalizedOptions) => string;
180
+ (schedule: Schedule, plan: PlanNode, opts: NormalizedOptions) => string;
181
+
182
+ return renderer(schedule, plan, opts);
183
+ }
184
+
185
+ // --- Dense multi-cadence restructure. ---
186
+
187
+ // Whether a field's shape is a true cadence — a repeating pattern (step, range,
188
+ // or enumerated list), not a wildcard or a single pinned value. A dense pattern
189
+ // stacks one of these in the second, the minute, and the hour.
190
+ function isCadenceShape(shape: Schedule['shapes'][keyof Schedule['shapes']]):
191
+ boolean {
192
+ return shape === 'step' || shape === 'range' || shape === 'list';
193
+ }
194
+
195
+ // A dense pattern is a seconds cadence stacked on a minutes cadence under an
196
+ // hours cadence: three independent cadences whose flat fine-to-coarse run-on
197
+ // reads as a robotic list. It is recognized only on the `composeSeconds` plan
198
+ // (a meaningful second over a coarser rest), with all three of second, minute,
199
+ // and hour a cadence, and no day union (which owns its own leading-month
200
+ // structure). The hour may take any cadence shape — a stride, a range window,
201
+ // or a list/range-with-outlier — each rendered in its own existing leaf form
202
+ // inside the restructured frame. A `clockTimes` rest is excluded: there the
203
+ // minute and hour fold into a named clock-time enumeration ("every 15 seconds
204
+ // of 9:00 a.m., 9:25 a.m., …"), a compact form already better than a run-on, so
205
+ // it is left as is. Restricted to the default dialect's voice — the same scope
206
+ // as the confinement frame — so other dialects and the compact `short` form
207
+ // keep their established phrasing.
208
+ function isDenseCadence(schedule: Schedule, opts: NormalizedOptions): boolean {
209
+ if (!opts.style.untilWindow || opts.short ||
210
+ schedule.plan.kind !== 'composeSeconds' ||
211
+ schedule.plan.rest.kind === 'clockTimes' ||
212
+ isDayUnion(schedule, opts)) {
213
+ return false;
214
+ }
167
215
 
168
- return renderer(ir, plan, opts);
169
- }
216
+ const {shapes} = schedule;
170
217
 
171
- // --- Seconds renderers. ---
218
+ return isCadenceShape(shapes.second) && isCadenceShape(shapes.minute) &&
219
+ isCadenceShape(shapes.hour);
220
+ }
172
221
 
173
- function renderEverySecond(ir: IR, plan: PlanOf<'everySecond'>,
222
+ // The coarse hour cadence as a standalone fragment: a stride reads as its
223
+ // bounded/bare cadence ("every five hours from midnight through 8 p.m.", "every
224
+ // six hours"); a plain range reads as its window ("from 8 a.m. through 6
225
+ // p.m."), the non-continuous form a stepped minute uses inside the range; a
226
+ // list or range-with-outlier reads as its "during the … hours" frame (the same
227
+ // phrasing the confinement form produces, just hoisted into the dense lead).
228
+ function denseHourFragment(schedule: Schedule,
174
229
  opts: NormalizedOptions): string {
175
- return 'every second' + trailingQualifier(ir, opts);
230
+ const stride = hourStride(schedule);
231
+
232
+ if (stride) {
233
+ return hourStrideCadence(stride, opts);
234
+ }
235
+
236
+ if (schedule.shapes.hour === 'range') {
237
+ // A plain range hour, whose single range segment carries the window bounds.
238
+ const segment = segmentsOf(schedule, 'hour').find(function range(part) {
239
+ return part.kind === 'range';
240
+ }) as Extract<Segment, {kind: 'range'}>;
241
+
242
+ return rangeWindow({
243
+ continuous: false,
244
+ from: +segment.bounds[0],
245
+ throughMinute: 0,
246
+ to: +segment.bounds[1]
247
+ }, opts);
248
+ }
249
+
250
+ // A list or range-with-outlier hour ("9-20,22") reads as the discrete
251
+ // "during the <times> hours" frame, the same construction the hour
252
+ // confinement uses for these shapes.
253
+ return 'during the ' +
254
+ hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
255
+ ' hours';
176
256
  }
177
257
 
178
- function renderStandaloneSeconds(ir: IR, plan: PlanOf<'standaloneSeconds'>,
258
+ // The minute cadence as a standalone fragment, counted past the hour: a step is
259
+ // its stride phrase, a range its "every minute from M through K" lead, and a
260
+ // list its stride-or-enumeration.
261
+ function denseMinuteFragment(schedule: Schedule,
179
262
  opts: NormalizedOptions): string {
180
- return secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
263
+ if (schedule.shapes.minute === 'step') {
264
+ return stepCycle60(stepSegment(schedule, 'minute'), 'minute', 'hour', opts);
265
+ }
266
+
267
+ if (schedule.shapes.minute === 'range') {
268
+ return minuteRangeLead(schedule.pattern.minute, opts);
269
+ }
270
+
271
+ // A minute list has segments; an offset/uneven step the core enumerated to a
272
+ // list reads as a stride when its fires form a progression.
273
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
274
+ opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
275
+ 'minute', 'hour', opts);
276
+ }
277
+
278
+ // Assemble the dense form, or null when the pattern is not dense. The calendar
279
+ // anchor leads ("on the last weekday of the month, …"), the cadences run
280
+ // coarse-to-fine (hour, then minute), and the second nests under the minute
281
+ // ("…, and within each of those minutes, every second …"). Each fragment is
282
+ // today's leaf phrasing, reordered and nested but otherwise unchanged.
283
+ function denseCadence(schedule: Schedule,
284
+ opts: NormalizedOptions): string | null {
285
+ if (!isDenseCadence(schedule, opts)) {
286
+ return null;
287
+ }
288
+
289
+ const hour = denseHourFragment(schedule, opts);
290
+ const minute = denseMinuteFragment(schedule, opts);
291
+ const second = secondsClause(schedule, 'minute', opts);
292
+ const nested = hour + ', ' + minute +
293
+ ', and within each of those minutes, ' + second;
294
+
295
+ // A trailing day qualifier (" on the last weekday of the month") leads the
296
+ // dense form instead; with no anchor the hour cadence leads alone.
297
+ const anchor = trailingQualifier(schedule, opts).trim();
298
+
299
+ return anchor ? anchor + ', ' + nested : nested;
181
300
  }
182
301
 
183
- function renderSecondPastMinute(ir: IR, plan: PlanOf<'secondPastMinute'>,
302
+ // --- Seconds renderers. ---
303
+
304
+ function renderEverySecond(schedule: Schedule, plan: PlanOf<'everySecond'>,
184
305
  opts: NormalizedOptions): string {
185
- const secondField = ir.pattern.second;
306
+ return 'every second' + trailingQualifier(schedule, opts);
307
+ }
308
+
309
+ function renderStandaloneSeconds(schedule: Schedule,
310
+ plan: PlanOf<'standaloneSeconds'>, opts: NormalizedOptions): string {
311
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
312
+ }
313
+
314
+ function renderSecondPastMinute(schedule: Schedule,
315
+ plan: PlanOf<'secondPastMinute'>, opts: NormalizedOptions): string {
316
+ const secondField = schedule.pattern.second;
186
317
 
187
318
  return getNumber(secondField, opts) + ' ' +
188
319
  pluralize(secondField, 'second') +
189
- ' past the minute, every minute' + trailingQualifier(ir, opts);
320
+ ' past the minute, every minute' + trailingQualifier(schedule, opts);
190
321
  }
191
322
 
192
323
  // A meaningful second combined with a single specific minute (and an open
193
324
  // hour). A single second folds into the minute anchor ("30 minutes and 15
194
325
  // seconds past the hour, every hour"); a list, range, or step leads with
195
326
  // its own clause.
196
- function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
197
- opts: NormalizedOptions): string {
198
- const minuteField = ir.pattern.minute;
327
+ function renderSecondsWithinMinute(schedule: Schedule,
328
+ plan: PlanOf<'secondsWithinMinute'>, opts: NormalizedOptions): string {
329
+ const minuteField = schedule.pattern.minute;
199
330
  const minuteWord = getNumber(minuteField, opts);
200
331
  const minuteUnit = pluralize(minuteField, 'minute');
201
332
 
202
333
  if (plan.singleSecond) {
203
- const secondField = ir.pattern.second;
334
+ const secondField = schedule.pattern.second;
204
335
 
205
336
  return minuteWord + ' ' + minuteUnit + ' and ' +
206
337
  getNumber(secondField, opts) + ' ' + pluralize(secondField, 'second') +
207
- ' past the hour, every hour' + trailingQualifier(ir, opts);
338
+ ' past the hour, every hour' + trailingQualifier(schedule, opts);
208
339
  }
209
340
 
210
- return secondsLeadClause(ir, opts) + ', ' + minuteWord + ' ' +
341
+ return secondsLeadClause(schedule, opts) + ', ' + minuteWord + ' ' +
211
342
  minuteUnit + ' past the hour, every hour' +
212
- trailingQualifier(ir, opts);
343
+ trailingQualifier(schedule, opts);
213
344
  }
214
345
 
215
346
  // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
216
347
  // would cross-multiply an hour stride under a single pinned minute, or null
217
348
  // when that does not apply (a non-clock rest, a multi-valued minute, or an
218
349
  // hour that is not a stride).
219
- function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
350
+ function composeHourCadence(schedule: Schedule, plan: PlanOf<'composeSeconds'>,
220
351
  opts: NormalizedOptions): string | null {
221
352
  const clockRest = plan.rest.kind === 'clockTimes' ||
222
353
  plan.rest.kind === 'compactClockTimes';
223
354
 
224
- if (!clockRest || ir.shapes.minute !== 'single') {
355
+ if (!clockRest || schedule.shapes.minute !== 'single') {
225
356
  return null;
226
357
  }
227
358
 
228
- const minute = +ir.pattern.minute;
359
+ const minute = +schedule.pattern.minute;
229
360
 
230
- return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
361
+ return hourCadence(schedule, minute, opts) ??
362
+ hourRangeCadence(schedule, minute, opts);
231
363
  }
232
364
 
233
365
  // A wildcard or stepped second under a fixed minute across one or more specific
@@ -243,23 +375,24 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
243
375
  // form, never collapsing to the bare hour (which once repeated it, "9 a.m.,
244
376
  // 9 a.m."). A non-zero pinned minute is an unambiguous clock time the compact
245
377
  // "of 9:05 a.m." form reads as the minute, never the hour.
246
- function clockTimesConfinement(ir: IR, rest: PlanOf<'clockTimes'>,
247
- opts: NormalizedOptions): string {
248
- if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
249
- return secondsLeadClause(ir, opts) + ' for one minute at ' +
250
- durationHours(ir, rest, opts);
378
+ function clockTimesConfinement(schedule: Schedule,
379
+ rest: PlanOf<'clockTimes'>, opts: NormalizedOptions): string {
380
+ if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
381
+ return secondsLeadClause(schedule, opts) + ' for one minute at ' +
382
+ durationHours(schedule, rest, opts);
251
383
  }
252
384
 
253
- return secondsLeadClause(ir, opts) + ' of ' + clockTimesOf(ir, rest, opts);
385
+ return secondsLeadClause(schedule, opts) + ' of ' +
386
+ clockTimesOf(schedule, rest, opts);
254
387
  }
255
388
 
256
- function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
257
- opts: NormalizedOptions): string {
389
+ function renderComposeSeconds(schedule: Schedule,
390
+ plan: PlanOf<'composeSeconds'>, opts: NormalizedOptions): string {
258
391
  // An hour step (or arithmetic-progression hour list) under a single pinned
259
392
  // minute is a cadence, not a wall of clock times: speak the second/minute
260
393
  // lead, then the hour cadence ("at 30 seconds past the hour, every two
261
394
  // hours"). The clock-time rest would otherwise cross-multiply the hours.
262
- const cadence = composeHourCadence(ir, plan, opts);
395
+ const cadence = composeHourCadence(schedule, plan, opts);
263
396
 
264
397
  if (cadence !== null) {
265
398
  return cadence;
@@ -268,8 +401,9 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
268
401
  // A wildcard or stepped second under a fixed minute across one or more
269
402
  // specific hours confines the seconds to the clock time(s).
270
403
  if (plan.rest.kind === 'clockTimes' &&
271
- (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
272
- return clockTimesConfinement(ir, plan.rest, opts);
404
+ (schedule.shapes.second === 'wildcard' ||
405
+ schedule.shapes.second === 'step')) {
406
+ return clockTimesConfinement(schedule, plan.rest, opts);
273
407
  }
274
408
 
275
409
  // A wildcard second under a */2 minute step with a wildcard hour binds
@@ -277,12 +411,12 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
277
411
  // the natural English for an interval of 2, and "of" joins the two without
278
412
  // the ambiguity of a comma, which reads as two independent cadences.
279
413
  // Scoped to */2 only; other step sizes keep the comma form.
280
- if (ir.shapes.second === 'wildcard' &&
414
+ if (schedule.shapes.second === 'wildcard' &&
281
415
  plan.rest.kind === 'minuteFrequency' &&
282
416
  plan.rest.hours.kind === 'none' &&
283
- ir.pattern.minute === '*/2') {
417
+ schedule.pattern.minute === '*/2') {
284
418
  return 'every second of every other minute' +
285
- trailingQualifier(ir, opts);
419
+ trailingQualifier(schedule, opts);
286
420
  }
287
421
 
288
422
  // A compact clock-time rest folds a meaningful SINGLE second into its own
@@ -290,22 +424,22 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
290
424
  // double it. A wildcard or stepped second is not folded there (no
291
425
  // clockSecond), so it still leads its own clause here.
292
426
  const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
293
- ir.analyses.clockSecond;
294
- const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
427
+ schedule.analyses.clockSecond;
428
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
295
429
 
296
- return lead + render(ir, plan.rest, opts);
430
+ return lead + render(schedule, plan.rest, opts);
297
431
  }
298
432
 
299
433
  // The bare-hour words for a minute-0 duration confinement, joined and followed
300
434
  // by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
301
435
  // 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
302
436
  // never "H:00", since the "for one minute" frame already carries the minute.
303
- function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
437
+ function durationHours(schedule: Schedule, plan: PlanOf<'clockTimes'>,
304
438
  opts: NormalizedOptions): string {
305
439
  const hours = plan.times.map(function clock(time) {
306
440
  return getTime({hour: time.hour, minute: 0}, opts);
307
441
  });
308
- const trail = dayQualifier(ir, leadingWords, opts);
442
+ const trail = dayQualifier(schedule, leadingWords, opts);
309
443
 
310
444
  return joinList(hours, opts) + (trail && ', ' + trail);
311
445
  }
@@ -313,7 +447,7 @@ function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
313
447
  // The clock times for a non-zero pinned-minute compose-seconds rest, joined
314
448
  // and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
315
449
  // every day". The non-zero minute reads as a clock time, never the hour.
316
- function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
450
+ function clockTimesOf(schedule: Schedule, plan: PlanOf<'clockTimes'>,
317
451
  opts: NormalizedOptions): string {
318
452
  const times = plan.times.map(function clock(time) {
319
453
  return getTime({
@@ -323,7 +457,7 @@ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
323
457
  explicit: true
324
458
  }, opts);
325
459
  });
326
- const trail = dayQualifier(ir, leadingWords, opts);
460
+ const trail = dayQualifier(schedule, leadingWords, opts);
327
461
 
328
462
  return joinList(times, opts) + (trail && ', ' + trail);
329
463
  }
@@ -331,8 +465,9 @@ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
331
465
  // The leading clause describing a second field relative to the minute,
332
466
  // e.g. "at 5 and 10 seconds past the minute" or "every second from zero
333
467
  // through 30 past the minute".
334
- function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
335
- return secondsClause(ir, 'minute', opts);
468
+ function secondsLeadClause(schedule: Schedule,
469
+ opts: NormalizedOptions): string {
470
+ return secondsClause(schedule, 'minute', opts);
336
471
  }
337
472
 
338
473
  // The second clause counted against an arbitrary anchor. The anchor is
@@ -340,10 +475,10 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
340
475
  // pinned minute 0 into the hour and counts the second "past the hour"
341
476
  // instead ("at 30 seconds past the hour", "every second from 0 through 10
342
477
  // past the hour"), so the minute-0 confinement is stated, not dropped.
343
- function secondsClause(ir: IR, anchor: string,
478
+ function secondsClause(schedule: Schedule, anchor: string,
344
479
  opts: NormalizedOptions): string {
345
- const secondField = ir.pattern.second;
346
- const shape = ir.shapes.second;
480
+ const secondField = schedule.pattern.second;
481
+ const shape = schedule.shapes.second;
347
482
 
348
483
  if (secondField === '*') {
349
484
  return 'every second';
@@ -352,7 +487,7 @@ function secondsClause(ir: IR, anchor: string,
352
487
  if (shape === 'step') {
353
488
  // The plan reached this clause only for a stepped second field, whose
354
489
  // first segment is always a step segment.
355
- return stepCycle60(stepSegment(ir, 'second'),
490
+ return stepCycle60(stepSegment(schedule, 'second'),
356
491
  'second', anchor, opts);
357
492
  }
358
493
 
@@ -372,63 +507,63 @@ function secondsClause(ir: IR, anchor: string,
372
507
  // A non-wildcard second under the list/step path always has segments. An
373
508
  // offset/uneven step the core enumerated to a fire list reads as a stride
374
509
  // cadence when those fires form a long-enough progression.
375
- return strideFromSegments(segmentsOf(ir, 'second'), 'second', anchor,
376
- opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'second'), opts),
510
+ return strideFromSegments(segmentsOf(schedule, 'second'), 'second', anchor,
511
+ opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'second'), opts),
377
512
  'second', anchor, opts);
378
513
  }
379
514
 
380
515
  // --- Minute renderers. ---
381
516
 
382
- function renderEveryMinute(ir: IR, plan: PlanOf<'everyMinute'>,
517
+ function renderEveryMinute(schedule: Schedule, plan: PlanOf<'everyMinute'>,
383
518
  opts: NormalizedOptions): string {
384
- return 'every minute' + trailingQualifier(ir, opts);
519
+ return 'every minute' + trailingQualifier(schedule, opts);
385
520
  }
386
521
 
387
- function renderSingleMinute(ir: IR, plan: PlanOf<'singleMinute'>,
522
+ function renderSingleMinute(schedule: Schedule, plan: PlanOf<'singleMinute'>,
388
523
  opts: NormalizedOptions): string {
389
- const minuteField = ir.pattern.minute;
524
+ const minuteField = schedule.pattern.minute;
390
525
 
391
526
  return getNumber(minuteField, opts) + ' ' +
392
527
  pluralize(minuteField, 'minute') +
393
- ' past the hour, every hour' + trailingQualifier(ir, opts);
528
+ ' past the hour, every hour' + trailingQualifier(schedule, opts);
394
529
  }
395
530
 
396
- function renderRangeOfMinutes(ir: IR, plan: PlanOf<'rangeOfMinutes'>,
397
- opts: NormalizedOptions): string {
398
- return minuteRangeLead(ir.pattern.minute, opts) +
399
- trailingQualifier(ir, opts);
531
+ function renderRangeOfMinutes(schedule: Schedule,
532
+ plan: PlanOf<'rangeOfMinutes'>, opts: NormalizedOptions): string {
533
+ return minuteRangeLead(schedule.pattern.minute, opts) +
534
+ trailingQualifier(schedule, opts);
400
535
  }
401
536
 
402
- function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
403
- opts: NormalizedOptions): string {
537
+ function renderMultipleMinutes(schedule: Schedule,
538
+ plan: PlanOf<'multipleMinutes'>, opts: NormalizedOptions): string {
404
539
  // A multiple-minutes plan is selected only for a minute list, which has
405
540
  // segments. An offset/uneven step the core enumerated to this list reads as
406
541
  // a stride cadence when the fires form a long-enough progression.
407
542
  const stride =
408
- strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts);
543
+ strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour', opts);
409
544
 
410
- return (stride ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'),
411
- opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
545
+ return (stride ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'),
546
+ opts), 'minute', 'hour', opts)) + trailingQualifier(schedule, opts);
412
547
  }
413
548
 
414
549
  // A repeating minute step, qualified by the active hour window(s).
415
- function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
416
- opts: NormalizedOptions): string {
550
+ function renderMinuteFrequency(schedule: Schedule,
551
+ plan: PlanOf<'minuteFrequency'>, opts: NormalizedOptions): string {
417
552
  // A minute-frequency plan is selected only for a stepped minute field,
418
553
  // which has segments.
419
- let phrase = stepCycle60(stepSegment(ir, 'minute'),
554
+ let phrase = stepCycle60(stepSegment(schedule, 'minute'),
420
555
  'minute', 'hour', opts);
421
556
 
422
557
  if (plan.hours.kind === 'during') {
423
558
  // A uneven hour stride confines the minute cadence to its own bounded hour
424
559
  // cadence ("every 15 minutes, every five hours from midnight through 8
425
560
  // p.m."); an irregular hour list still names each hour's window.
426
- const cadence = unevenHourCadence(ir, opts);
561
+ const cadence = unevenHourCadence(schedule, opts);
427
562
 
428
563
  phrase += cadence ?
429
564
  ', ' + cadence :
430
565
  ' during the ' +
431
- hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
566
+ hourTimesFromPlan(schedule, plan.hours.times, false, opts) + ' hours';
432
567
  }
433
568
  else if (plan.hours.kind === 'window') {
434
569
  // A minute-frequency cadence ("every 15 minutes") fills the hours from a
@@ -447,10 +582,10 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
447
582
  // which confines the cadence to every Nth hour; a stepped hour field's
448
583
  // first segment is a step segment.
449
584
  phrase += ' ' +
450
- everyNthHour(stepSegment(ir, 'hour'), opts);
585
+ everyNthHour(stepSegment(schedule, 'hour'), opts);
451
586
  }
452
587
 
453
- return phrase + trailingQualifier(ir, opts);
588
+ return phrase + trailingQualifier(schedule, opts);
454
589
  }
455
590
 
456
591
  // A minute wildcard or plain range under a single specific hour fires
@@ -458,43 +593,43 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
458
593
  // whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
459
594
  // hour") rather than a synthesized "from H:00 through H:59" range the source
460
595
  // never stated; a plain range is a real window and keeps "from … through …".
461
- function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
462
- opts: NormalizedOptions): string {
463
- if (ir.pattern.minute === '*') {
596
+ function renderMinuteSpanInHour(schedule: Schedule,
597
+ plan: PlanOf<'minuteSpanInHour'>, opts: NormalizedOptions): string {
598
+ if (schedule.pattern.minute === '*') {
464
599
  return 'every minute of the ' +
465
600
  getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
466
- trailingQualifier(ir, opts);
601
+ trailingQualifier(schedule, opts);
467
602
  }
468
603
 
469
604
  return 'every minute from ' +
470
605
  getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
471
606
  through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
472
- trailingQualifier(ir, opts);
607
+ trailingQualifier(schedule, opts);
473
608
  }
474
609
 
475
610
  // A minute window combined with discrete hours fires within that window
476
611
  // during each hour.
477
- function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
478
- opts: NormalizedOptions): string {
612
+ function renderMinutesAcrossHours(schedule: Schedule,
613
+ plan: PlanOf<'minutesAcrossHours'>, opts: NormalizedOptions): string {
479
614
  // A uneven hour stride reads as a cadence, not a wall of hour columns: the
480
615
  // minute lead, then "every N hours from X through Y".
481
- const cadence = unevenHourCadence(ir, opts);
616
+ const cadence = unevenHourCadence(schedule, opts);
482
617
 
483
618
  if (plan.form === 'wildcard') {
484
619
  if (cadence !== null) {
485
- return 'every minute, ' + cadence + trailingQualifier(ir, opts);
620
+ return 'every minute, ' + cadence + trailingQualifier(schedule, opts);
486
621
  }
487
622
 
488
623
  return 'every minute during the ' +
489
- hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
490
- trailingQualifier(ir, opts);
624
+ hourTimesFromPlan(schedule, plan.times, false, opts) + ' hours' +
625
+ trailingQualifier(schedule, opts);
491
626
  }
492
627
 
493
628
  if (plan.form === 'range') {
494
- const lead = minuteRangeLead(ir.pattern.minute, opts);
629
+ const lead = minuteRangeLead(schedule.pattern.minute, opts);
495
630
 
496
631
  if (cadence !== null) {
497
- return lead + ', ' + cadence + trailingQualifier(ir, opts);
632
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
498
633
  }
499
634
 
500
635
  // A plain minute range is a cadence, so an hour list confines it with the
@@ -505,30 +640,31 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
505
640
  // hour, at 9 a.m."), never the plural "hours" confinement.
506
641
  if (singleHourFire(plan.times)) {
507
642
  return lead + ', at ' +
508
- hourTimesFromPlan(ir, plan.times, true, opts) +
509
- trailingQualifier(ir, opts);
643
+ hourTimesFromPlan(schedule, plan.times, true, opts) +
644
+ trailingQualifier(schedule, opts);
510
645
  }
511
646
 
512
647
  return lead + ' during the ' +
513
- hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
514
- trailingQualifier(ir, opts);
648
+ hourTimesFromPlan(schedule, plan.times, false, opts) + ' hours' +
649
+ trailingQualifier(schedule, opts);
515
650
  }
516
651
 
517
652
  // The 'list' form is a minute list, which has segments; an offset/uneven
518
653
  // step enumerated to that list reads as a stride. A list is a set of
519
654
  // discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
520
655
  const lead =
521
- strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
522
- listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
656
+ strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
657
+ opts) ??
658
+ listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
523
659
  'minute', 'hour', opts);
524
660
 
525
661
  if (cadence !== null) {
526
- return lead + ', ' + cadence + trailingQualifier(ir, opts);
662
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
527
663
  }
528
664
 
529
- const times = hourTimesFromPlan(ir, plan.times, true, opts);
665
+ const times = hourTimesFromPlan(schedule, plan.times, true, opts);
530
666
 
531
- return lead + ', at ' + times + trailingQualifier(ir, opts);
667
+ return lead + ', at ' + times + trailingQualifier(schedule, opts);
532
668
  }
533
669
 
534
670
  // Spelled ordinals for "during every Nth hour" — the clean hour-step
@@ -551,34 +687,35 @@ function everyNthHour(segment: StepSegment, opts: NormalizedOptions): string {
551
687
  // A minute wildcard or plain range under an hour step. A wildcard minute (a
552
688
  // cadence) is reached only for a clean step and is confined to every Nth hour;
553
689
  // a plain range is a per-hour window whose recurrence trails as its own clause.
554
- function renderMinuteSpanAcrossHourStep(ir: IR,
690
+ function renderMinuteSpanAcrossHourStep(schedule: Schedule,
555
691
  plan: PlanOf<'minuteSpanAcrossHourStep'>, opts: NormalizedOptions): string {
556
692
  // This plan is reached only under a stepped hour field, whose first
557
693
  // segment is a step segment.
558
- const segment = stepSegment(ir, 'hour');
694
+ const segment = stepSegment(schedule, 'hour');
559
695
 
560
696
  // A wildcard minute over a stepped hour is reached only for a clean stride
561
697
  // (a bounded or uneven step routes through minutesAcrossHours instead), so it
562
698
  // confines to every Nth hour without a bounded-cadence case here.
563
699
  if (plan.form === 'wildcard') {
564
700
  return 'every minute ' + everyNthHour(segment, opts) +
565
- trailingQualifier(ir, opts);
701
+ trailingQualifier(schedule, opts);
566
702
  }
567
703
 
568
704
  // A minute list keeps the same cadence clause; only its lead differs. An
569
705
  // offset/uneven step the core enumerated to that list reads as a stride.
570
706
  const lead = plan.form === 'list' ?
571
- strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
572
- listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
707
+ strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
708
+ opts) ??
709
+ listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
573
710
  'minute', 'hour', opts) :
574
- minuteRangeLead(ir.pattern.minute, opts);
711
+ minuteRangeLead(schedule.pattern.minute, opts);
575
712
  // A bounded or uneven hour step reads as its endpoint-pinning cadence after
576
713
  // the minute lead, not a wall of clock-time columns; an offset-clean step
577
714
  // keeps its existing per-step phrasing.
578
- const cadence = unevenHourCadence(ir, opts);
715
+ const cadence = unevenHourCadence(schedule, opts);
579
716
 
580
717
  return lead + ', ' +
581
- (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
718
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
582
719
  }
583
720
 
584
721
  // Lead phrase for a plain minute range: "every minute from <a> through <b>
@@ -594,60 +731,60 @@ function minuteRangeLead(minuteField: string,
594
731
 
595
732
  // --- Hour renderers. ---
596
733
 
597
- function renderEveryHour(ir: IR, plan: PlanOf<'everyHour'>,
734
+ function renderEveryHour(schedule: Schedule, plan: PlanOf<'everyHour'>,
598
735
  opts: NormalizedOptions): string {
599
- return 'every hour' + trailingQualifier(ir, opts);
736
+ return 'every hour' + trailingQualifier(schedule, opts);
600
737
  }
601
738
 
602
739
  // An hour range fires within a window: on the hour it reads "every hour
603
740
  // from 9 a.m. through 5 p.m."; a minute wildcard or range fires every
604
741
  // minute; a discrete minute anchors as a lead clause.
605
- function renderHourRange(ir: IR, plan: PlanOf<'hourRange'>,
742
+ function renderHourRange(schedule: Schedule, plan: PlanOf<'hourRange'>,
606
743
  opts: NormalizedOptions): string {
607
744
  const window = hourWindow(boundedWindow(plan), opts);
608
745
 
609
746
  if (plan.minuteForm === 'wildcard') {
610
- return 'every minute ' + window + trailingQualifier(ir, opts);
747
+ return 'every minute ' + window + trailingQualifier(schedule, opts);
611
748
  }
612
749
 
613
750
  if (plan.minuteForm === 'range') {
614
- return minuteRangeLead(ir.pattern.minute, opts) + ', ' + window +
615
- trailingQualifier(ir, opts);
751
+ return minuteRangeLead(schedule.pattern.minute, opts) + ', ' + window +
752
+ trailingQualifier(schedule, opts);
616
753
  }
617
754
 
618
- return rangeMinuteLead(ir, opts) + ' ' + window +
619
- trailingQualifier(ir, opts);
755
+ return rangeMinuteLead(schedule, opts) + ' ' + window +
756
+ trailingQualifier(schedule, opts);
620
757
  }
621
758
 
622
759
  // Lead phrase for a discrete minute within an hour range: on-the-hour
623
760
  // reads "every hour"; otherwise the minute list anchors it.
624
- function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
625
- if (ir.pattern.minute === '0') {
761
+ function rangeMinuteLead(schedule: Schedule, opts: NormalizedOptions): string {
762
+ if (schedule.pattern.minute === '0') {
626
763
  return 'every hour';
627
764
  }
628
765
 
629
766
  // A non-"0" minute here is a discrete list, which has segments; an
630
767
  // offset/uneven step enumerated to that list reads as a stride.
631
- return strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour',
632
- opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
768
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
769
+ opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
633
770
  'minute', 'hour', opts);
634
771
  }
635
772
 
636
- function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
773
+ function renderHourStep(schedule: Schedule, plan: PlanOf<'hourStep'>,
637
774
  opts: NormalizedOptions): string {
638
775
  // A bounded or uneven hour step reads as its endpoint-pinning cadence ("every
639
776
  // two hours from 9 a.m. through 5 p.m."), the same form the compound paths
640
777
  // speak; an offset-clean step keeps its bare or "from M" cadence.
641
- const cadence = unevenHourCadence(ir, opts);
778
+ const cadence = unevenHourCadence(schedule, opts);
642
779
 
643
780
  if (cadence !== null) {
644
- return cadence + trailingQualifier(ir, opts);
781
+ return cadence + trailingQualifier(schedule, opts);
645
782
  }
646
783
 
647
784
  // An hour-step plan is selected only for a stepped hour field, whose
648
785
  // first segment is a step segment.
649
- return stepHours(stepSegment(ir, 'hour'), opts) +
650
- trailingQualifier(ir, opts);
786
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
787
+ trailingQualifier(schedule, opts);
651
788
  }
652
789
 
653
790
  // The hour-range plan as a window. The close lands on the top of the final
@@ -714,15 +851,15 @@ function hourWindow(
714
851
 
715
852
  // Expand a discrete set of hours and minutes into clock times prefixed by
716
853
  // a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
717
- function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
854
+ function renderClockTimes(schedule: Schedule, plan: PlanOf<'clockTimes'>,
718
855
  opts: NormalizedOptions): string {
719
856
  // An hour step or range (or arithmetic-progression hour list) under a
720
857
  // single pinned minute reads as a cadence or window rather than a
721
858
  // cross-product of clock times.
722
- if (ir.shapes.minute === 'single') {
723
- const minute = +ir.pattern.minute;
724
- const cadence = hourCadence(ir, minute, opts) ??
725
- hourRangeCadence(ir, minute, opts);
859
+ if (schedule.shapes.minute === 'single') {
860
+ const minute = +schedule.pattern.minute;
861
+ const cadence = hourCadence(schedule, minute, opts) ??
862
+ hourRangeCadence(schedule, minute, opts);
726
863
 
727
864
  if (cadence !== null) {
728
865
  return cadence;
@@ -739,28 +876,28 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
739
876
  }, opts);
740
877
  });
741
878
 
742
- return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts) +
743
- dayUnionTrail(ir, opts);
879
+ return interpretDayQualifier(schedule, opts) + 'at ' + joinList(times, opts) +
880
+ dayUnionTrail(schedule, opts);
744
881
  }
745
882
 
746
883
  // The trailing day-union condition for a clock-time form (which leads with its
747
884
  // time, not a day qualifier), or an empty string when the pattern is not a day
748
885
  // union. The cadence renderers carry this through `trailingQualifier` instead.
749
- function dayUnionTrail(ir: IR, opts: NormalizedOptions): string {
750
- return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : '';
886
+ function dayUnionTrail(schedule: Schedule, opts: NormalizedOptions): string {
887
+ return isDayUnion(schedule, opts) ? dayUnionCondition(schedule, opts) : '';
751
888
  }
752
889
 
753
890
  // Compact form for a clock-time set past the enumeration cap. A single
754
891
  // minute folds into per-segment hour windows; a minute list leads with its
755
892
  // own clause instead of cross-multiplying into a wall of times.
756
- function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
757
- opts: NormalizedOptions): string {
893
+ function renderCompactClockTimes(schedule: Schedule,
894
+ plan: PlanOf<'compactClockTimes'>, opts: NormalizedOptions): string {
758
895
  if (plan.fold) {
759
896
  // An hour step or range (or arithmetic-progression hour list) under the
760
897
  // single pinned minute reads as a cadence or window, not a wall of clock
761
898
  // times. (Returns null for an irregular list, which keeps folding below.)
762
- const cadence = hourCadence(ir, +plan.minute, opts) ??
763
- hourRangeCadence(ir, +plan.minute, opts);
899
+ const cadence = hourCadence(schedule, +plan.minute, opts) ??
900
+ hourRangeCadence(schedule, +plan.minute, opts);
764
901
 
765
902
  if (cadence !== null) {
766
903
  return cadence;
@@ -768,41 +905,45 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
768
905
 
769
906
  // A compact clock-time plan is reached only for discrete hours, which
770
907
  // have segments.
771
- const hasRange = segmentsOf(ir, 'hour').some(function range(segment) {
908
+ const hasRange = segmentsOf(schedule, 'hour').some(function range(segment) {
772
909
  return segment.kind === 'range';
773
910
  });
774
911
 
775
912
  // A contiguous hour range reads with the hour-range frame ("every
776
913
  // hour from X through Y"), not a clock-time span ("at X through Y").
777
- if (hasRange && !ir.analyses.clockSecond) {
778
- return foldedHourWindows(ir, plan, opts) + trailingQualifier(ir, opts);
914
+ if (hasRange && !schedule.analyses.clockSecond) {
915
+ return foldedHourWindows(schedule, plan, opts) +
916
+ trailingQualifier(schedule, opts);
779
917
  }
780
918
 
781
- const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
919
+ const fold = {minute: plan.minute, second: schedule.analyses.clockSecond};
782
920
 
783
- return interpretDayQualifier(ir, opts) + 'at ' +
784
- hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
921
+ return interpretDayQualifier(schedule, opts) + 'at ' +
922
+ hourSegmentTimes(schedule, fold, true, opts) +
923
+ dayUnionTrail(schedule, opts);
785
924
  }
786
925
 
787
926
  const minuteLead =
788
927
  // The non-fold branch is a minute list, which has segments. An
789
928
  // offset/uneven step enumerated to that list reads as a stride.
790
- strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
791
- listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
929
+ strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
930
+ opts) ??
931
+ listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
792
932
  'minute', 'hour', opts);
793
933
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
794
934
  // of clock-time columns.
795
- const cadence = unevenHourCadence(ir, opts);
935
+ const cadence = unevenHourCadence(schedule, opts);
796
936
  const phrase = cadence ?
797
- minuteLead + ', ' + cadence + trailingQualifier(ir, opts) :
937
+ minuteLead + ', ' + cadence + trailingQualifier(schedule, opts) :
798
938
  minuteLead +
799
- ', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
800
- trailingQualifier(ir, opts);
939
+ ', at ' +
940
+ hourSegmentTimes(schedule, {minute: 0, second: null}, true, opts) +
941
+ trailingQualifier(schedule, opts);
801
942
 
802
943
  // A single non-zero second cannot fold into the per-minute clause, so it
803
944
  // leads with its own.
804
- return ir.analyses.clockSecond ?
805
- secondsLeadClause(ir, opts) + ', ' + phrase :
945
+ return schedule.analyses.clockSecond ?
946
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
806
947
  phrase;
807
948
  }
808
949
 
@@ -810,18 +951,18 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
810
951
  // hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
811
952
  // past the hour"), each range as a window, and any non-contiguous hour
812
953
  // appended by `outlierTail` ("and at Z").
813
- function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
814
- opts: NormalizedOptions): string {
954
+ function foldedHourWindows(schedule: Schedule,
955
+ plan: PlanOf<'compactClockTimes'>, opts: NormalizedOptions): string {
815
956
  const minute = plan.minute;
816
957
  const windows: string[] = [];
817
- const times = collectHourOutliers(ir).map(function time(hour) {
958
+ const times = collectHourOutliers(schedule).map(function time(hour) {
818
959
  return getTime({hour, minute}, opts);
819
960
  });
820
961
 
821
962
  // Reached only via the fold branch under discrete hours, which have segments.
822
963
  // A folded minute is a discrete pin/list, never a wildcard, so the run is not
823
964
  // continuous to the top of the next hour: the window is not an until-window.
824
- segmentsOf(ir, 'hour').forEach(function classify(segment) {
965
+ segmentsOf(schedule, 'hour').forEach(function classify(segment) {
825
966
  if (segment.kind === 'range') {
826
967
  windows.push(rangeWindow({
827
968
  continuous: false,
@@ -832,18 +973,19 @@ function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
832
973
  }
833
974
  });
834
975
 
835
- const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
976
+ const phrase = rangeMinuteLead(schedule, opts) + ' ' +
977
+ joinList(windows, opts);
836
978
 
837
979
  return phrase + outlierTail(times, opts);
838
980
  }
839
981
 
840
982
  // The hours outside a contiguous run — every non-range segment's values, with
841
983
  // a step contributing its whole fire set.
842
- function collectHourOutliers(ir: IR): number[] {
984
+ function collectHourOutliers(schedule: Schedule): number[] {
843
985
  const hours: number[] = [];
844
986
 
845
987
  // Reached only under discrete hours, which carry segments.
846
- segmentsOf(ir, 'hour').forEach(function classify(segment) {
988
+ segmentsOf(schedule, 'hour').forEach(function classify(segment) {
847
989
  if (segment.kind === 'step') {
848
990
  hours.push(...segment.fires);
849
991
  }
@@ -894,19 +1036,19 @@ function isCadenceField(token: string): boolean {
894
1036
  // the pattern has no cadence lead (the finest restricted field is a clock-point
895
1037
  // single/range/list). The seconds lead when restricted as a cadence; otherwise
896
1038
  // the minute leads when the second is a plain :00 and the minute is a cadence.
897
- function leadingCadence(ir: IR, opts: NormalizedOptions):
1039
+ function leadingCadence(schedule: Schedule, opts: NormalizedOptions):
898
1040
  {text: string; secondLead: boolean} | null {
899
- const {second, minute} = ir.pattern;
1041
+ const {second, minute} = schedule.pattern;
900
1042
 
901
1043
  if (isCadenceField(second)) {
902
- return {secondLead: true, text: secondsClause(ir, 'minute', opts)};
1044
+ return {secondLead: true, text: secondsClause(schedule, 'minute', opts)};
903
1045
  }
904
1046
 
905
1047
  if (second === '0' && isCadenceField(minute)) {
906
1048
  const text = minute === '*' ?
907
1049
  'every minute' :
908
1050
  // A clean minute step's first segment is a step segment.
909
- stepCycle60(stepSegment(ir, 'minute'),
1051
+ stepCycle60(stepSegment(schedule, 'minute'),
910
1052
  'minute', 'hour', opts);
911
1053
 
912
1054
  return {secondLead: false, text};
@@ -919,8 +1061,9 @@ function leadingCadence(ir: IR, opts: NormalizedOptions):
919
1061
  // confinement: "during minute :NN", "during minutes :NN through :MM", "during
920
1062
  // minutes :NN and :MM". A clean minute step reads "of every other minute". A
921
1063
  // wildcard minute is redundant under the seconds cadence and drops (empty).
922
- function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
923
- const minute = ir.pattern.minute;
1064
+ function minuteConfinement(schedule: Schedule,
1065
+ opts: NormalizedOptions): string {
1066
+ const minute = schedule.pattern.minute;
924
1067
 
925
1068
  if (minute === '*') {
926
1069
  return '';
@@ -935,13 +1078,13 @@ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
935
1078
  // A minute single/range/list under the seconds lead. The minute reads as a
936
1079
  // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
937
1080
  // the minute-lead clock-point form).
938
- const segments = segmentsOf(ir, 'minute');
1081
+ const segments = segmentsOf(schedule, 'minute');
939
1082
 
940
- if (ir.shapes.minute === 'single') {
1083
+ if (schedule.shapes.minute === 'single') {
941
1084
  return ' during minute :' + pad(minute);
942
1085
  }
943
1086
 
944
- if (ir.shapes.minute === 'range') {
1087
+ if (schedule.shapes.minute === 'range') {
945
1088
  const bounds = minute.split('-');
946
1089
 
947
1090
  return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
@@ -962,15 +1105,15 @@ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
962
1105
  // ("of the midnight hour"). A clean hour step is "of every other hour"; a range
963
1106
  // reuses the until-window; a list or stepped range reads "during the … hours".
964
1107
  // A wildcard hour drops (empty).
965
- function hourConfinement(ir: IR, opts: NormalizedOptions): string {
966
- const hour = ir.pattern.hour;
1108
+ function hourConfinement(schedule: Schedule, opts: NormalizedOptions): string {
1109
+ const hour = schedule.pattern.hour;
967
1110
 
968
1111
  if (hour === '*') {
969
1112
  // A pinned minute confinement ("during minute :00") repeats across every
970
1113
  // hour, so the hour is named as the unit of recurrence; a stepped minute
971
1114
  // ("of every other minute") or absent minute already implies all hours.
972
- const minutePinned = ir.pattern.minute !== '*' &&
973
- !isCadenceField(ir.pattern.minute);
1115
+ const minutePinned = schedule.pattern.minute !== '*' &&
1116
+ !isCadenceField(schedule.pattern.minute);
974
1117
 
975
1118
  return minutePinned ? ' of every hour' : '';
976
1119
  }
@@ -979,10 +1122,10 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
979
1122
  return hour === '*/2' ? ' of every other hour' : '';
980
1123
  }
981
1124
 
982
- if (ir.shapes.hour === 'single') {
1125
+ if (schedule.shapes.hour === 'single') {
983
1126
  const h = +hour;
984
1127
 
985
- if (ir.shapes.minute === 'step') {
1128
+ if (schedule.shapes.minute === 'step') {
986
1129
  return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
987
1130
  getTime({hour: (h + 1) % 24, minute: 0}, opts);
988
1131
  }
@@ -990,21 +1133,22 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
990
1133
  // A pinned minute confinement already named the minute, so the hour reads
991
1134
  // as a plain clock point; a wildcard or absent minute makes the hour the
992
1135
  // unit of recurrence ("of the midnight hour").
993
- if (ir.pattern.minute !== '*' && !isCadenceField(ir.pattern.minute)) {
1136
+ if (schedule.pattern.minute !== '*' &&
1137
+ !isCadenceField(schedule.pattern.minute)) {
994
1138
  return ' at ' + getTime({hour: h, minute: 0}, opts);
995
1139
  }
996
1140
 
997
1141
  return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
998
1142
  }
999
1143
 
1000
- if (ir.shapes.hour === 'range') {
1144
+ if (schedule.shapes.hour === 'range') {
1001
1145
  const bounds = hour.split('-');
1002
1146
 
1003
1147
  // The until-window holds only when the run is continuous to the top of the
1004
1148
  // next hour — a wildcard minute fills every minute of the final hour; a
1005
1149
  // confined minute (":00", a step) stops within it, reading "through".
1006
1150
  return ' ' + rangeWindow({
1007
- continuous: ir.pattern.minute === '*',
1151
+ continuous: schedule.pattern.minute === '*',
1008
1152
  from: +bounds[0],
1009
1153
  throughMinute: 0,
1010
1154
  to: +bounds[1]
@@ -1013,16 +1157,8 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
1013
1157
 
1014
1158
  // An hour list or stepped range reads "during the <times> hours".
1015
1159
  return ' during the ' +
1016
- hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) + ' hours';
1017
- }
1018
-
1019
- // Whether the hour field reads as a contiguous window — a real range whose
1020
- // close depends on the finer field's last fire. A finer STEP cadence does not
1021
- // fill the closing hour ("from 9 a.m. until 5:45 p.m."), so that window is left
1022
- // to the existing windowing renderer rather than the confinement frame, which
1023
- // closes on the top of the next hour ("until 6 p.m.").
1024
- function isContiguousHourRange(ir: IR): boolean {
1025
- return ir.shapes.hour === 'range';
1160
+ hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
1161
+ ' hours';
1026
1162
  }
1027
1163
 
1028
1164
  // Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
@@ -1031,26 +1167,27 @@ function isContiguousHourRange(ir: IR): boolean {
1031
1167
  // idiom ("of every other hour"), so other open steps defer. A BOUNDED stepped
1032
1168
  // range (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the
1033
1169
  // confinement frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
1034
- function confinableHour(ir: IR): boolean {
1035
- if (ir.shapes.hour !== 'step') {
1170
+ function confinableHour(schedule: Schedule): boolean {
1171
+ if (schedule.shapes.hour !== 'step') {
1036
1172
  return true;
1037
1173
  }
1038
1174
 
1039
1175
  // Reached only under a stepped hour, whose first segment is a step segment.
1040
- const segment = stepSegment(ir, 'hour');
1176
+ const segment = stepSegment(schedule, 'hour');
1041
1177
 
1042
- return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
1178
+ return schedule.pattern.hour === '*/2' ||
1179
+ segment.startToken.indexOf('-') !== -1;
1043
1180
  }
1044
1181
 
1045
1182
  // Whether a minute list is really a stride the existing renderer speaks as a
1046
1183
  // cadence ("every two minutes from 3 through 59"): such a progression is not a
1047
1184
  // short explicit ":NN" confinement, so it defers.
1048
- function isMinuteStride(ir: IR): boolean {
1049
- if (ir.shapes.minute !== 'list') {
1185
+ function isMinuteStride(schedule: Schedule): boolean {
1186
+ if (schedule.shapes.minute !== 'list') {
1050
1187
  return false;
1051
1188
  }
1052
1189
 
1053
- const values = singleValues(segmentsOf(ir, 'minute'));
1190
+ const values = singleValues(segmentsOf(schedule, 'minute'));
1054
1191
 
1055
1192
  return values !== null && arithmeticStep(values) !== null;
1056
1193
  }
@@ -1059,13 +1196,13 @@ function isMinuteStride(ir: IR): boolean {
1059
1196
  // frame covers a finer leading cadence (seconds, or minute under a :00 second)
1060
1197
  // with each coarser field as a confinement; shapes outside it defer to the
1061
1198
  // existing renderers, which already produce that phrasing for them.
1062
- function confinementEligible(ir: IR,
1199
+ function confinementEligible(schedule: Schedule,
1063
1200
  lead: {secondLead: boolean}): boolean {
1064
- const {minute, hour} = ir.pattern;
1201
+ const {minute, hour} = schedule.pattern;
1065
1202
  const minuteStep = isCadenceField(minute) && minute !== '*';
1066
1203
 
1067
1204
  // A non-`*/2` hour stride keeps the existing cadence form.
1068
- if (!confinableHour(ir)) {
1205
+ if (!confinableHour(schedule)) {
1069
1206
  return false;
1070
1207
  }
1071
1208
 
@@ -1074,17 +1211,20 @@ function confinementEligible(ir: IR,
1074
1211
  // and only where it fills the coarser field: a contiguous hour range or a
1075
1212
  // single hour both close on the minute's real last fire, which the
1076
1213
  // windowing renderer already speaks. The `*/2` step fills both, so it keeps
1077
- // the "of every other minute" confinement; other steps defer entirely.
1214
+ // the "of every other minute" confinement; other steps defer entirely. A
1215
+ // contiguous hour range (`hour === 'range'`) is left to that windowing
1216
+ // renderer rather than this confinement frame, which closes on the top of
1217
+ // the next hour.
1078
1218
  if (minuteStep) {
1079
- return minute === '*/2' && !isContiguousHourRange(ir);
1219
+ return minute === '*/2' && schedule.shapes.hour !== 'range';
1080
1220
  }
1081
1221
 
1082
1222
  // A minute list that is really a stride keeps its cadence form; a short
1083
1223
  // explicit minute list crossed with a discrete hour LIST is a wall of
1084
1224
  // distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
1085
1225
  // confinement. Both stay with the enumerating renderer.
1086
- if (isMinuteStride(ir) ||
1087
- ir.shapes.minute === 'list' && ir.shapes.hour === 'list') {
1226
+ if (isMinuteStride(schedule) ||
1227
+ schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
1088
1228
  return false;
1089
1229
  }
1090
1230
 
@@ -1100,14 +1240,15 @@ function confinementEligible(ir: IR,
1100
1240
  return true;
1101
1241
  }
1102
1242
 
1103
- return ir.shapes.hour === 'single' && minute === '*/2';
1243
+ return schedule.shapes.hour === 'single' && minute === '*/2';
1104
1244
  }
1105
1245
 
1106
1246
  // Render the pattern with the confinement frame: a finer leading cadence with
1107
1247
  // each coarser field as a confinement, or null when it does not apply. Routed
1108
1248
  // to from the cadence renderers in place of the older juxtaposed-cadence and
1109
1249
  // duration-frame forms.
1110
- function confinement(ir: IR, opts: NormalizedOptions): string | null {
1250
+ function confinement(schedule: Schedule,
1251
+ opts: NormalizedOptions): string | null {
1111
1252
  // The confinement frame is scoped to the default (US) dialect, the one that
1112
1253
  // carries the until-window; every other dialect and the compact `short` form
1113
1254
  // keep their established juxtaposed-cadence / duration-frame phrasing.
@@ -1118,20 +1259,20 @@ function confinement(ir: IR, opts: NormalizedOptions): string | null {
1118
1259
  // With nothing coarser to confine (minute and hour both wildcard), the bare
1119
1260
  // cadence renderers already speak the pattern ("every second", "every
1120
1261
  // minute"); the confinement frame only applies once a coarser field is set.
1121
- if (ir.pattern.minute === '*' && ir.pattern.hour === '*') {
1262
+ if (schedule.pattern.minute === '*' && schedule.pattern.hour === '*') {
1122
1263
  return null;
1123
1264
  }
1124
1265
 
1125
- const lead = leadingCadence(ir, opts);
1266
+ const lead = leadingCadence(schedule, opts);
1126
1267
 
1127
- if (!lead || !confinementEligible(ir, lead)) {
1268
+ if (!lead || !confinementEligible(schedule, lead)) {
1128
1269
  return null;
1129
1270
  }
1130
1271
 
1131
- const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : '';
1272
+ const minutePart = lead.secondLead ? minuteConfinement(schedule, opts) : '';
1132
1273
 
1133
- return lead.text + minutePart + hourConfinement(ir, opts) +
1134
- trailingQualifier(ir, opts);
1274
+ return lead.text + minutePart + hourConfinement(schedule, opts) +
1275
+ trailingQualifier(schedule, opts);
1135
1276
  }
1136
1277
 
1137
1278
  // The plan dispatch table.
@@ -1171,31 +1312,30 @@ const renderers = {
1171
1312
  function renderStride(stride: Stride, opts: NormalizedOptions): string {
1172
1313
  const {interval, start, last, cycle, unit, anchor} = stride;
1173
1314
  const cadence = 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
1174
- const tiles = cycle % interval === 0;
1175
1315
 
1176
- if (start === 0 && tiles) {
1177
- return cadence;
1178
- }
1316
+ return chooseStride({start, interval, cycle}, {
1317
+ bare: () => cadence,
1179
1318
 
1180
- if (start < interval && tiles) {
1181
1319
  // A clean wrap from a non-zero offset: name the start, no endpoint.
1182
- return cadence + ' from ' + getNumber(start, opts) + ' ' +
1183
- pluralize(start, unit) + ' past the ' + anchor;
1184
- }
1320
+ offset: () => cadence + ' from ' + getNumber(start, opts) + ' ' +
1321
+ pluralize(start, unit) + ' past the ' + anchor,
1185
1322
 
1186
- // A bounded, non-wrapping set: pin both endpoints. Each bound is a value, so
1187
- // it reads as a digit, matching the range idiom ("from 0 through 30").
1188
- const num = seriesNumber();
1323
+ // A bounded, non-wrapping set: pin both endpoints. Each bound is a value,
1324
+ // so it reads as a digit, matching the range idiom ("from 0 through 30").
1325
+ bounded: () => {
1326
+ const num = seriesNumber();
1189
1327
 
1190
- return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
1191
- pluralize(last, unit) + ' past the ' + anchor;
1328
+ return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
1329
+ pluralize(last, unit) + ' past the ' + anchor;
1330
+ }
1331
+ });
1192
1332
  }
1193
1333
 
1194
1334
  // Speak a minute/second field's enumerated fires as a step cadence when they
1195
1335
  // form an arithmetic progression long enough to beat the list (the core
1196
- // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1197
- // the renderer recognizes the progression). Returns null for a non-progression
1198
- // or a too-short list, leaving the caller to enumerate.
1336
+ // enumerates an offset/uneven step to this fire list; the Schedule is
1337
+ // unchanged, so the renderer recognizes the progression). Returns null for a
1338
+ // non-progression or a too-short list, leaving the caller to enumerate.
1199
1339
  function strideFromSegments(segments: Segment[], unit: string, anchor: string,
1200
1340
  opts: NormalizedOptions): string | null {
1201
1341
  const values = singleValues(segments);
@@ -1278,18 +1418,14 @@ function hourStrideCadence(stride: {start: number; interval: number;
1278
1418
  last: number}, opts: NormalizedOptions): string {
1279
1419
  const {start, interval, last} = stride;
1280
1420
  const cadence = 'every ' + getNumber(interval, opts) + ' hours';
1281
- const tiles = 24 % interval === 0;
1282
-
1283
- if (start === 0 && tiles) {
1284
- return cadence;
1285
- }
1286
-
1287
- if (start < interval && tiles) {
1288
- return cadence + ' from ' + getTime({hour: start, minute: 0}, opts);
1289
- }
1290
1421
 
1291
- return cadence + ' from ' + getTime({hour: start, minute: 0}, opts) +
1292
- through(opts) + getTime({hour: last, minute: 0}, opts);
1422
+ return chooseStride({start, interval, cycle: 24}, {
1423
+ bare: () => cadence,
1424
+ offset: () => cadence + ' from ' + getTime({hour: start, minute: 0}, opts),
1425
+ bounded: () =>
1426
+ cadence + ' from ' + getTime({hour: start, minute: 0}, opts) +
1427
+ through(opts) + getTime({hour: last, minute: 0}, opts)
1428
+ });
1293
1429
  }
1294
1430
 
1295
1431
  // The bounded cadence for an hour stride that pins both clock-time endpoints,
@@ -1299,8 +1435,9 @@ function hourStrideCadence(stride: {start: number; interval: number;
1299
1435
  // ("…, every five hours from midnight through 8 p.m.") than as a wall of
1300
1436
  // clock-time columns. An offset-clean stride keeps its existing confinement
1301
1437
  // form, so only the endpoint-bearing case routes here.
1302
- function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1303
- const stride = hourStride(ir);
1438
+ function unevenHourCadence(schedule: Schedule,
1439
+ opts: NormalizedOptions): string | null {
1440
+ const stride = hourStride(schedule);
1304
1441
 
1305
1442
  if (!stride || offsetCleanStride(stride)) {
1306
1443
  return null;
@@ -1312,14 +1449,14 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1312
1449
  // The hour field's stride, or null when the hour is not a cadence: a step
1313
1450
  // segment yields its {start, interval, last} directly; an all-single hour
1314
1451
  // list yields one only when its values form a step progression (so an irregular
1315
- // list like 9,17 keeps enumerating). The IR is unchanged — the renderer
1452
+ // list like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
1316
1453
  // recognizes the stride and speaks it as a cadence instead of the clock-time
1317
1454
  // cross-product.
1318
- function hourStride(ir: IR):
1455
+ function hourStride(schedule: Schedule):
1319
1456
  {start: number; interval: number; last: number} | null {
1320
1457
  // Reached only from the clock-time paths, which run under discrete hours
1321
1458
  // and so always carry hour segments.
1322
- const segments = segmentsOf(ir, 'hour');
1459
+ const segments = segmentsOf(schedule, 'hour');
1323
1460
 
1324
1461
  if (segments.length === 1 && segments[0].kind === 'step') {
1325
1462
  const segment = segments[0];
@@ -1346,8 +1483,8 @@ function hourStride(ir: IR):
1346
1483
  // The second's status against a pinned minute: a wildcard or sub-minute step
1347
1484
  // fills the minute (a "for one minute" frame at minute 0); a single 0 is just
1348
1485
  // the top of the minute (no clause); anything else needs its own clause.
1349
- function subMinuteSecond(ir: IR): boolean {
1350
- return ir.pattern.second === '*' || ir.shapes.second === 'step';
1486
+ function subMinuteSecond(schedule: Schedule): boolean {
1487
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1351
1488
  }
1352
1489
 
1353
1490
  // The lead clause for an hour-cadence rendering: the second and the pinned
@@ -1357,14 +1494,14 @@ function subMinuteSecond(ir: IR): boolean {
1357
1494
  // minute" frame (the whole minute-0 window). A non-zero minute is a real
1358
1495
  // clock minute: the second leads with its own "past the minute" clause (if
1359
1496
  // any), then the minute reads "M minutes past the hour".
1360
- function hourCadenceLead(ir: IR, minute: number,
1497
+ function hourCadenceLead(schedule: Schedule, minute: number,
1361
1498
  opts: NormalizedOptions): string {
1362
1499
  if (minute === 0) {
1363
- if (subMinuteSecond(ir)) {
1364
- return secondsClause(ir, 'minute', opts) + ' for one minute';
1500
+ if (subMinuteSecond(schedule)) {
1501
+ return secondsClause(schedule, 'minute', opts) + ' for one minute';
1365
1502
  }
1366
1503
 
1367
- return secondsClause(ir, 'hour', opts);
1504
+ return secondsClause(schedule, 'hour', opts);
1368
1505
  }
1369
1506
 
1370
1507
  const minutePhrase = getNumber(minute, opts) + ' ' +
@@ -1372,11 +1509,11 @@ function hourCadenceLead(ir: IR, minute: number,
1372
1509
 
1373
1510
  // A single 0 second is just the top of the minute, so the minute leads
1374
1511
  // alone; any other second prefixes its own clause.
1375
- if (ir.pattern.second === '0') {
1512
+ if (schedule.pattern.second === '0') {
1376
1513
  return minutePhrase;
1377
1514
  }
1378
1515
 
1379
- return secondsClause(ir, 'minute', opts) + ', ' + minutePhrase;
1516
+ return secondsClause(schedule, 'minute', opts) + ', ' + minutePhrase;
1380
1517
  }
1381
1518
 
1382
1519
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1388,10 +1525,10 @@ function hourCadenceLead(ir: IR, minute: number,
1388
1525
  // but a plain :00) makes every clock time three digit-groups, so any stride
1389
1526
  // is worth compacting; otherwise the stride must exceed the clock-time cap,
1390
1527
  // the same point at which the core itself stops enumerating. Renderer-only;
1391
- // the IR is unchanged.
1392
- function hourCadence(ir: IR, minute: number,
1528
+ // the Schedule is unchanged.
1529
+ function hourCadence(schedule: Schedule, minute: number,
1393
1530
  opts: NormalizedOptions): string | null {
1394
- const stride = hourStride(ir);
1531
+ const stride = hourStride(schedule);
1395
1532
 
1396
1533
  if (!stride) {
1397
1534
  return null;
@@ -1404,7 +1541,7 @@ function hourCadence(ir: IR, minute: number,
1404
1541
  // or "from M" form is no shorter than the list, so the list reads fine. A
1405
1542
  // bounded or uneven stride has no clean wrap, so its endpoint-pinning cadence
1406
1543
  // ("every five hours from midnight through 8 p.m.") reads better however few.
1407
- if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1544
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1408
1545
  offsetCleanStride(stride)) {
1409
1546
  return null;
1410
1547
  }
@@ -1414,33 +1551,33 @@ function hourCadence(ir: IR, minute: number,
1414
1551
  // minute during every other hour", matching the "every minute during every
1415
1552
  // other hour" idiom and keeping it distinct from the bare hour-step form
1416
1553
  // ("every two hours") so the minute-0 confinement is never heard as it.
1417
- const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
1418
- cleanStrideSegment(ir);
1554
+ const minuteZeroStride = minute === 0 && subMinuteSecond(schedule) &&
1555
+ cleanStrideSegment(schedule);
1419
1556
 
1420
1557
  if (minuteZeroStride) {
1421
- return secondsClause(ir, 'minute', opts) + ' for one minute ' +
1422
- everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
1558
+ return secondsClause(schedule, 'minute', opts) + ' for one minute ' +
1559
+ everyNthHour(minuteZeroStride, opts) + trailingQualifier(schedule, opts);
1423
1560
  }
1424
1561
 
1425
1562
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1426
1563
  // lead clause to fold in, so the bounded cadence stands on its own ("every
1427
1564
  // five hours from midnight through 8 p.m."); only a real minute or second
1428
1565
  // prefixes its clause.
1429
- if (minute === 0 && ir.pattern.second === '0') {
1430
- return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1566
+ if (minute === 0 && schedule.pattern.second === '0') {
1567
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1431
1568
  }
1432
1569
 
1433
- return hourCadenceLead(ir, minute, opts) + ', ' +
1434
- hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1570
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1571
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1435
1572
  }
1436
1573
 
1437
1574
  // The hour step segment when the hour is a clean stride with an idiomatic
1438
1575
  // ordinal ("every other", "every sixth"), suitable for the "during every Nth
1439
1576
  // hour" confinement frame; null otherwise (an uneven stride, a bounded step,
1440
1577
  // or an arithmetic-progression list, which keep the bounded cadence form).
1441
- function cleanStrideSegment(ir: IR): StepSegment | null {
1578
+ function cleanStrideSegment(schedule: Schedule): StepSegment | null {
1442
1579
  // Reached only after hourStride confirmed a stride, so hour segments exist.
1443
- const segments = segmentsOf(ir, 'hour');
1580
+ const segments = segmentsOf(schedule, 'hour');
1444
1581
  const segment = segments.length === 1 && segments[0];
1445
1582
 
1446
1583
  if (!segment || segment.kind !== 'step' ||
@@ -1457,10 +1594,10 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1457
1594
  // A pure single-value list (9,17) has no range to span and still enumerates;
1458
1595
  // a step is handled by hourStride/hourCadence, so a field whose only segments
1459
1596
  // are steps and singles is left alone here.
1460
- function hasHourWindow(ir: IR): boolean {
1597
+ function hasHourWindow(schedule: Schedule): boolean {
1461
1598
  // Reached only from the clock-time paths, which run under discrete hours
1462
1599
  // and so always carry hour segments.
1463
- return segmentsOf(ir, 'hour').some(function range(segment) {
1600
+ return segmentsOf(schedule, 'hour').some(function range(segment) {
1464
1601
  return segment.kind === 'range';
1465
1602
  });
1466
1603
  }
@@ -1472,12 +1609,13 @@ function hasHourWindow(ir: IR): boolean {
1472
1609
  // minute, never a wildcard — so the run is not continuous to the top of the
1473
1610
  // next hour and the window keeps "through". Mirrors foldedHourWindows but
1474
1611
  // pinned to minute 0.
1475
- function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1612
+ function hourRangeWindowTail(schedule: Schedule,
1613
+ opts: NormalizedOptions): string {
1476
1614
  const windows: string[] = [];
1477
- const outlierHours = collectHourOutliers(ir);
1615
+ const outlierHours = collectHourOutliers(schedule);
1478
1616
 
1479
1617
  // Reached only after hasHourWindow, so hour segments exist.
1480
- segmentsOf(ir, 'hour').forEach(function classify(segment) {
1618
+ segmentsOf(schedule, 'hour').forEach(function classify(segment) {
1481
1619
  if (segment.kind === 'range') {
1482
1620
  windows.push(rangeWindow({
1483
1621
  continuous: false,
@@ -1502,21 +1640,21 @@ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1502
1640
  // the hours into a wall of clock times. Returns null when the hour has no
1503
1641
  // range (a pure single-value list, a single hour, or a step, which other
1504
1642
  // paths own), or when a plain :00 set is short enough that enumeration is no
1505
- // longer than the window. Renderer-only; the IR is unchanged.
1506
- function hourRangeCadence(ir: IR, minute: number,
1643
+ // longer than the window. Renderer-only; the Schedule is unchanged.
1644
+ function hourRangeCadence(schedule: Schedule, minute: number,
1507
1645
  opts: NormalizedOptions): string | null {
1508
1646
  // Scoped to minute 0: the minute folds into the lead and every hour fires
1509
1647
  // at the top, so the window closes cleanly on the final hour. A non-zero
1510
1648
  // pinned minute is a real clock minute the existing clock-time window form
1511
1649
  // already speaks ("9:30:15 a.m. through 8:30:15 p.m."), unchanged.
1512
- if (minute !== 0 || !hasHourWindow(ir)) {
1650
+ if (minute !== 0 || !hasHourWindow(schedule)) {
1513
1651
  return null;
1514
1652
  }
1515
1653
 
1516
1654
  // A plain top-of-minute second (:00) carries no clause: the existing
1517
1655
  // hour-range and folded-window renderers already speak that window, so this
1518
1656
  // path only forms a window when there is a meaningful second to lead with.
1519
- if (ir.pattern.second === '0') {
1657
+ if (schedule.pattern.second === '0') {
1520
1658
  return null;
1521
1659
  }
1522
1660
 
@@ -1526,14 +1664,15 @@ function hourRangeCadence(ir: IR, minute: number,
1526
1664
  // uses). This is kept distinct from the bare minute-0 window ("every hour
1527
1665
  // from 9 a.m. through 5 p.m.") so the one-minute confinement is never heard
1528
1666
  // as it — the hour-range analog of "for one minute during every other hour".
1529
- if (subMinuteSecond(ir)) {
1530
- return secondsClause(ir, 'minute', opts) + ' for one minute during the ' +
1531
- hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) +
1532
- ' hours' + trailingQualifier(ir, opts);
1667
+ if (subMinuteSecond(schedule)) {
1668
+ return secondsClause(schedule, 'minute', opts) +
1669
+ ' for one minute during the ' +
1670
+ hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
1671
+ ' hours' + trailingQualifier(schedule, opts);
1533
1672
  }
1534
1673
 
1535
- return hourCadenceLead(ir, minute, opts) + ', ' +
1536
- hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
1674
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1675
+ hourRangeWindowTail(schedule, opts) + trailingQualifier(schedule, opts);
1537
1676
  }
1538
1677
 
1539
1678
  // --- List and segment phrasing. ---
@@ -1649,13 +1788,13 @@ function singleHourFire(times: HourTimesPlan): boolean {
1649
1788
  // The hour times accompanying a window phrase: enumerated fires up to the
1650
1789
  // cap, segment rendering past it (decided by the core). `atContext` marks
1651
1790
  // an "at <times>" frame (vs "during the <times> hours").
1652
- function hourTimesFromPlan(ir: IR, times: HourTimesPlan, atContext: boolean,
1653
- opts: NormalizedOptions): string {
1791
+ function hourTimesFromPlan(schedule: Schedule, times: HourTimesPlan,
1792
+ atContext: boolean, opts: NormalizedOptions): string {
1654
1793
  if (times.kind === 'fires') {
1655
1794
  return hourTimes(times.fires, opts);
1656
1795
  }
1657
1796
 
1658
- return hourSegmentTimes(ir, {minute: 0, second: null}, atContext, opts);
1797
+ return hourSegmentTimes(schedule, {minute: 0, second: null}, atContext, opts);
1659
1798
  }
1660
1799
 
1661
1800
  // The hour values an hour segment covers: a range's bounds, a step's
@@ -1671,13 +1810,13 @@ function segmentHours(segment: Segment): (string | number)[] {
1671
1810
  // Clock times for the hour field rendered segment by segment, so ranges
1672
1811
  // read as windows ("9:30 a.m. through 8:30 p.m.") rather than an
1673
1812
  // enumeration. The minute (and optional second) fold into each time.
1674
- function hourSegmentTimes(ir: IR,
1813
+ function hourSegmentTimes(schedule: Schedule,
1675
1814
  fold: {minute: number | string; second: number | null | undefined},
1676
1815
  atContext: boolean, opts: NormalizedOptions): string {
1677
1816
  const {minute, second} = fold;
1678
1817
  // Hour-segment rendering is reached only under discrete hours, which have
1679
1818
  // segments.
1680
- const segments = segmentsOf(ir, 'hour');
1819
+ const segments = segmentsOf(schedule, 'hour');
1681
1820
  const plain = mixedTwelve(segments.flatMap(function entries(segment) {
1682
1821
  return segmentHours(segment).map(function entry(hour) {
1683
1822
  return {hour: +hour, minute, second};
@@ -1785,47 +1924,49 @@ const leadingWords: QualifierWords = {
1785
1924
 
1786
1925
  // A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
1787
1926
  // Returns an empty string when no date, month, or weekday is set.
1788
- function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
1927
+ function trailingQualifier(schedule: Schedule,
1928
+ opts: NormalizedOptions): string {
1789
1929
  // A day union reframes both day fields as a trailing condition clause; the
1790
1930
  // month leads the whole description (applied in `describe`), so it is not
1791
1931
  // part of the trailing qualifier here.
1792
- if (isDayUnion(ir, opts)) {
1793
- return dayUnionCondition(ir, opts);
1932
+ if (isDayUnion(schedule, opts)) {
1933
+ return dayUnionCondition(schedule, opts);
1794
1934
  }
1795
1935
 
1796
- const phrase = dayQualifier(ir, trailingWords, opts);
1936
+ const phrase = dayQualifier(schedule, trailingWords, opts);
1797
1937
 
1798
1938
  return phrase && ' ' + phrase;
1799
1939
  }
1800
1940
 
1801
1941
  // Build the day-level qualifier that precedes a specific time, e.g.
1802
1942
  // "every day ", "every Friday ", or "on January 13 ".
1803
- function interpretDayQualifier(ir: IR, opts: NormalizedOptions): string {
1943
+ function interpretDayQualifier(schedule: Schedule,
1944
+ opts: NormalizedOptions): string {
1804
1945
  // A day union puts the time first ("at midnight whenever the day is …"), so
1805
1946
  // the leading position contributes no day phrase; the condition clause is
1806
1947
  // appended after the time by the clock renderer.
1807
- if (isDayUnion(ir, opts)) {
1948
+ if (isDayUnion(schedule, opts)) {
1808
1949
  return '';
1809
1950
  }
1810
1951
 
1811
- return dayQualifier(ir, leadingWords, opts) + ' ';
1952
+ return dayQualifier(schedule, leadingWords, opts) + ' ';
1812
1953
  }
1813
1954
 
1814
1955
  // The day-level qualifier phrase (date, month, and weekday), or
1815
1956
  // `words.all` when all three are wildcards. `words` supplies the
1816
1957
  // connectives that differ between the trailing and leading positions.
1817
- function dayQualifier(ir: IR, words: QualifierWords,
1958
+ function dayQualifier(schedule: Schedule, words: QualifierWords,
1818
1959
  opts: NormalizedOptions): string {
1819
- const pattern = ir.pattern;
1960
+ const pattern = schedule.pattern;
1820
1961
 
1821
1962
  // Standard cron fires when day-of-month OR day-of-week matches, when
1822
1963
  // both are restricted.
1823
1964
  if (pattern.date !== '*' && pattern.weekday !== '*') {
1824
- return dateOrWeekday(ir, opts);
1965
+ return dateOrWeekday(schedule, opts);
1825
1966
  }
1826
1967
 
1827
1968
  if (pattern.date !== '*') {
1828
- return datePhrase(ir, words, opts);
1969
+ return datePhrase(schedule, words, opts);
1829
1970
  }
1830
1971
 
1831
1972
  // A weekday qualifier, optionally scoped to a month ("on Monday in
@@ -1837,46 +1978,47 @@ function dayQualifier(ir: IR, words: QualifierWords,
1837
1978
  // the "of the month" recurrence a concrete month makes redundant; a plain
1838
1979
  // weekday name takes the ordinary " in <month>" scope.
1839
1980
  if (quartzWeekday) {
1840
- return monthScopeForRecurrence(quartzWeekday, ir, opts);
1981
+ return monthScopeForRecurrence(quartzWeekday, schedule, opts);
1841
1982
  }
1842
1983
 
1843
1984
  const weekdays = words.weekday +
1844
- weekdayPhrase(ir, words.recurringWeekday, opts);
1985
+ weekdayPhrase(schedule, words.recurringWeekday, opts);
1845
1986
 
1846
- return weekdays + monthScope(ir, opts);
1987
+ return weekdays + monthScope(schedule, opts);
1847
1988
  }
1848
1989
 
1849
1990
  if (pattern.month !== '*') {
1850
- return words.month + monthName(ir, opts);
1991
+ return words.month + monthName(schedule, opts);
1851
1992
  }
1852
1993
 
1853
1994
  return words.all;
1854
1995
  }
1855
1996
 
1856
1997
  // The date portion of a day qualifier (the weekday is a wildcard).
1857
- function datePhrase(ir: IR, words: QualifierWords,
1998
+ function datePhrase(schedule: Schedule, words: QualifierWords,
1858
1999
  opts: NormalizedOptions): string {
1859
- const pattern = ir.pattern;
2000
+ const pattern = schedule.pattern;
1860
2001
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1861
2002
 
1862
2003
  if (quartzDate) {
1863
- return monthScopeForRecurrence(quartzDate, ir, opts);
2004
+ return monthScopeForRecurrence(quartzDate, schedule, opts);
1864
2005
  }
1865
2006
 
1866
2007
  if (isOpenStep(pattern.date)) {
1867
2008
  return monthScopeForRecurrence(
1868
- words.stepDate + stepDates(pattern.date), ir, opts);
2009
+ words.stepDate + stepDates(pattern.date), schedule, opts);
1869
2010
  }
1870
2011
 
1871
- if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
1872
- return 'on the ' + dateOrdinals(ir, opts) + monthScope(ir, opts);
2012
+ if (pattern.month !== '*' && !monthFoldsIntoDate(schedule)) {
2013
+ return 'on the ' + dateOrdinals(schedule, opts) +
2014
+ monthScope(schedule, opts);
1873
2015
  }
1874
2016
 
1875
2017
  if (pattern.month !== '*') {
1876
- return 'on ' + monthDatePhrase(ir, opts);
2018
+ return 'on ' + monthDatePhrase(schedule, opts);
1877
2019
  }
1878
2020
 
1879
- return 'on the ' + dateOrdinals(ir, opts);
2021
+ return 'on the ' + dateOrdinals(schedule, opts);
1880
2022
  }
1881
2023
 
1882
2024
  // Whether the month can fold into a calendar date ("on June 1"): flat name
@@ -1885,10 +2027,10 @@ function datePhrase(ir: IR, words: QualifierWords,
1885
2027
  // "(June) through (September 1)" — and the "every odd/even-numbered month"
1886
2028
  // frequency phrase has no name to place before the date; both scope the date
1887
2029
  // instead ("on the 1st in June through September").
1888
- function monthFoldsIntoDate(ir: IR): boolean {
1889
- return !oddEvenMonth(ir.pattern.month) &&
2030
+ function monthFoldsIntoDate(schedule: Schedule): boolean {
2031
+ return !oddEvenMonth(schedule.pattern.month) &&
1890
2032
  // Reached only with a restricted month, which has segments.
1891
- segmentsOf(ir, 'month').every(function flat(segment) {
2033
+ segmentsOf(schedule, 'month').every(function flat(segment) {
1892
2034
  return segment.kind !== 'range';
1893
2035
  });
1894
2036
  }
@@ -1904,17 +2046,18 @@ function monthFoldsIntoDate(ir: IR): boolean {
1904
2046
  // and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
1905
2047
  // to the until-window dialect; every other dialect and the `short` form keep
1906
2048
  // the established "on <dom> or on <dow>" phrasing.
1907
- function isDayUnion(ir: IR, opts: NormalizedOptions): boolean {
1908
- return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
2049
+ function isDayUnion(schedule: Schedule, opts: NormalizedOptions): boolean {
2050
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*' &&
1909
2051
  !!opts.style.untilWindow && !opts.short;
1910
2052
  }
1911
2053
 
1912
2054
  // The trailing condition clause for a day union, e.g. " whenever the day is
1913
2055
  // the 1st or a Friday". The day predicates are flattened into one or-list so
1914
2056
  // the union reads as a single set of matching days.
1915
- function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
1916
- const pieces = [...dayUnionDatePieces(ir, opts),
1917
- ...dayUnionWeekdayPieces(ir, opts)];
2057
+ function dayUnionCondition(schedule: Schedule,
2058
+ opts: NormalizedOptions): string {
2059
+ const pieces = [...dayUnionDatePieces(schedule, opts),
2060
+ ...dayUnionWeekdayPieces(schedule, opts)];
1918
2061
 
1919
2062
  return ' whenever the day is ' + joinOr(pieces, opts);
1920
2063
  }
@@ -1922,12 +2065,13 @@ function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
1922
2065
  // The leading "in <month> " scope for a day union, or an empty string when the
1923
2066
  // month is a wildcard. The month scopes the whole union, so it leads the clause
1924
2067
  // rather than attaching to either day half.
1925
- function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
1926
- if (ir.pattern.month === '*') {
2068
+ function dayUnionMonthLead(schedule: Schedule,
2069
+ opts: NormalizedOptions): string {
2070
+ if (schedule.pattern.month === '*') {
1927
2071
  return '';
1928
2072
  }
1929
2073
 
1930
- return 'in ' + monthName(ir, opts) + ' ';
2074
+ return 'in ' + monthName(schedule, opts) + ' ';
1931
2075
  }
1932
2076
 
1933
2077
  // The day-of-month half of a union as a flat list of predicate pieces. A
@@ -1935,8 +2079,9 @@ function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
1935
2079
  // `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
1936
2080
  // reads each segment as "the <ordinal>" or "from the <ordinal> through the
1937
2081
  // <ordinal>".
1938
- function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1939
- const dateField = ir.pattern.date;
2082
+ function dayUnionDatePieces(schedule: Schedule,
2083
+ opts: NormalizedOptions): string[] {
2084
+ const dateField = schedule.pattern.date;
1940
2085
  const quartz = quartzDatePhrase(dateField, opts);
1941
2086
 
1942
2087
  if (quartz) {
@@ -1954,7 +2099,7 @@ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1954
2099
  // spreads its enumerated fires as separate "the <ordinal>" alternatives.
1955
2100
  const pieces: string[] = [];
1956
2101
 
1957
- segmentsOf(ir, 'date').forEach(function expand(segment) {
2102
+ segmentsOf(schedule, 'date').forEach(function expand(segment) {
1958
2103
  if (segment.kind === 'range') {
1959
2104
  pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
1960
2105
  'the ' + getOrdinal(segment.bounds[1]));
@@ -1977,8 +2122,9 @@ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
1977
2122
  // through-Friday range is the "a weekday" idiom; every other weekday names each
1978
2123
  // day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
1979
2124
  // kind of day the union can match.
1980
- function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
1981
- const weekdayField = ir.pattern.weekday;
2125
+ function dayUnionWeekdayPieces(schedule: Schedule,
2126
+ opts: NormalizedOptions): string[] {
2127
+ const weekdayField = schedule.pattern.weekday;
1982
2128
  const quartz = quartzWeekdayPhrase(weekdayField, opts);
1983
2129
 
1984
2130
  if (quartz) {
@@ -1991,7 +2137,7 @@ function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
1991
2137
  // Sunday, a Tuesday, a Thursday, or a Saturday").
1992
2138
  const pieces: string[] = [];
1993
2139
 
1994
- segmentsOf(ir, 'weekday').forEach(function expand(segment) {
2140
+ segmentsOf(schedule, 'weekday').forEach(function expand(segment) {
1995
2141
  if (segment.kind === 'range' &&
1996
2142
  segment.bounds[0] === '1' && segment.bounds[1] === '5') {
1997
2143
  pieces.push('a weekday');
@@ -2042,26 +2188,27 @@ function oddEvenDay(dateField: string): string | null {
2042
2188
  // names itself on the weekday ("or on Friday in June"), keeping both halves
2043
2189
  // scoped; otherwise (a Quartz date, an open day step, a month range, or the
2044
2190
  // odd/even frequency) it trails the whole or as ", in <month>".
2045
- function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
2046
- const pattern = ir.pattern;
2191
+ function dateOrWeekday(schedule: Schedule, opts: NormalizedOptions): string {
2192
+ const pattern = schedule.pattern;
2047
2193
  // The day-of-month-OR-day-of-week union is out of scope for the recurring
2048
2194
  // plural (it is reframed elsewhere): the weekday half stays singular here.
2049
2195
  const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
2050
- 'on ' + weekdayPhrase(ir, false, opts);
2196
+ 'on ' + weekdayPhrase(schedule, false, opts);
2051
2197
 
2052
- if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
2198
+ if (pattern.month !== '*' && monthFoldsIntoDate(schedule) &&
2053
2199
  !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
2054
- return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
2055
- ' in ' + monthName(ir, opts);
2200
+ return 'on ' + monthDatePhrase(schedule, opts) + ' or ' + weekdayPart +
2201
+ ' in ' + monthName(schedule, opts);
2056
2202
  }
2057
2203
 
2058
- return datePart(ir, opts) + ' or ' + weekdayPart + orMonthScope(ir, opts);
2204
+ return datePart(schedule, opts) + ' or ' + weekdayPart +
2205
+ orMonthScope(schedule, opts);
2059
2206
  }
2060
2207
 
2061
2208
  // The day-of-month half of an or-day phrase, without any month scope (the
2062
2209
  // month scopes the whole or, applied by the caller).
2063
- function datePart(ir: IR, opts: NormalizedOptions): string {
2064
- const pattern = ir.pattern;
2210
+ function datePart(schedule: Schedule, opts: NormalizedOptions): string {
2211
+ const pattern = schedule.pattern;
2065
2212
  const quartzDate = quartzDatePhrase(pattern.date, opts);
2066
2213
 
2067
2214
  if (quartzDate) {
@@ -2072,18 +2219,18 @@ function datePart(ir: IR, opts: NormalizedOptions): string {
2072
2219
  return stepDates(pattern.date);
2073
2220
  }
2074
2221
 
2075
- return 'on the ' + dateOrdinals(ir, opts);
2222
+ return 'on the ' + dateOrdinals(schedule, opts);
2076
2223
  }
2077
2224
 
2078
2225
  // A trailing month scope for the whole or, set off by a comma so it reads
2079
2226
  // over both day halves ("…or on Friday, in June"); empty when the month is a
2080
2227
  // wildcard.
2081
- function orMonthScope(ir: IR, opts: NormalizedOptions): string {
2082
- if (ir.pattern.month === '*') {
2228
+ function orMonthScope(schedule: Schedule, opts: NormalizedOptions): string {
2229
+ if (schedule.pattern.month === '*') {
2083
2230
  return '';
2084
2231
  }
2085
2232
 
2086
- return ', in ' + monthName(ir, opts);
2233
+ return ', in ' + monthName(schedule, opts);
2087
2234
  }
2088
2235
 
2089
2236
  // The day-qualifier phrase for a Quartz date field (e.g. "on the last day
@@ -2140,16 +2287,16 @@ function quartzWeekdayPhrase(weekdayField: string,
2140
2287
  // reads as if the 13 belongs to January alone. The day is reattached to the
2141
2288
  // whole list with the possessive "the <ordinal> of <months>", which names the
2142
2289
  // same day across every month unambiguously.
2143
- function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
2144
- const month = monthName(ir, opts);
2290
+ function monthDatePhrase(schedule: Schedule, opts: NormalizedOptions): string {
2291
+ const month = monthName(schedule, opts);
2145
2292
  // A month-day phrase is reached only with a restricted date, which has
2146
2293
  // segments.
2147
- const days = renderSegments(segmentsOf(ir, 'date'),
2294
+ const days = renderSegments(segmentsOf(schedule, 'date'),
2148
2295
  opts.style.ordinals ? getOrdinal : cardinalDay, opts);
2149
2296
 
2150
- if (opts.style.dayFirst && ir.shapes.date === 'single' &&
2151
- ir.shapes.month !== 'single') {
2152
- return 'the ' + getOrdinal(ir.pattern.date) + ' of ' + month;
2297
+ if (opts.style.dayFirst && schedule.shapes.date === 'single' &&
2298
+ schedule.shapes.month !== 'single') {
2299
+ return 'the ' + getOrdinal(schedule.pattern.date) + ' of ' + month;
2153
2300
  }
2154
2301
 
2155
2302
  return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
@@ -2162,12 +2309,12 @@ function cardinalDay(value: number | string): string {
2162
2309
 
2163
2310
  // A trailing " in <month>" scope, or an empty string when the month is a
2164
2311
  // wildcard.
2165
- function monthScope(ir: IR, opts: NormalizedOptions): string {
2166
- if (ir.pattern.month === '*') {
2312
+ function monthScope(schedule: Schedule, opts: NormalizedOptions): string {
2313
+ if (schedule.pattern.month === '*') {
2167
2314
  return '';
2168
2315
  }
2169
2316
 
2170
- return ' in ' + monthName(ir, opts);
2317
+ return ' in ' + monthName(schedule, opts);
2171
2318
  }
2172
2319
 
2173
2320
  // Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
@@ -2178,25 +2325,27 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
2178
2325
  // distributes the recurrence across the span and keeps it, rephrased as "of
2179
2326
  // each month from <first> through <last>". A month list is left as-is (the
2180
2327
  // recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
2181
- function monthScopeForRecurrence(phrase: string, ir: IR,
2328
+ function monthScopeForRecurrence(phrase: string, schedule: Schedule,
2182
2329
  opts: NormalizedOptions): string {
2183
- if (ir.pattern.month === '*') {
2330
+ if (schedule.pattern.month === '*') {
2184
2331
  return phrase;
2185
2332
  }
2186
2333
 
2187
2334
  const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
2188
2335
 
2189
- if (carriesRecurrence && ir.shapes.month === 'range') {
2336
+ if (carriesRecurrence && schedule.shapes.month === 'range') {
2190
2337
  return phrase.replace(' of the month', ' of each month') + ' from ' +
2191
- monthName(ir, opts);
2338
+ monthName(schedule, opts);
2192
2339
  }
2193
2340
 
2194
2341
  if (carriesRecurrence &&
2195
- (ir.shapes.month === 'single' || ir.shapes.month === 'step')) {
2196
- return phrase.replace(' of the month', '') + ' in ' + monthName(ir, opts);
2342
+ (schedule.shapes.month === 'single' ||
2343
+ schedule.shapes.month === 'step')) {
2344
+ return phrase.replace(' of the month', '') + ' in ' +
2345
+ monthName(schedule, opts);
2197
2346
  }
2198
2347
 
2199
- return phrase + ' in ' + monthName(ir, opts);
2348
+ return phrase + ' in ' + monthName(schedule, opts);
2200
2349
  }
2201
2350
 
2202
2351
  // Frequency phrase for an open day-of-month step, e.g. "every other day of
@@ -2219,17 +2368,17 @@ function stepDates(dateField: string): string {
2219
2368
 
2220
2369
  // Render the date field's segments as suffixed ordinals. Open steps are
2221
2370
  // handled separately as a frequency phrase.
2222
- function dateOrdinals(ir: IR, opts: NormalizedOptions): string {
2371
+ function dateOrdinals(schedule: Schedule, opts: NormalizedOptions): string {
2223
2372
  // Reached only with a restricted date, which has segments.
2224
- return renderSegments(segmentsOf(ir, 'date'), getOrdinal, opts);
2373
+ return renderSegments(segmentsOf(schedule, 'date'), getOrdinal, opts);
2225
2374
  }
2226
2375
 
2227
2376
  // Render the month field as names. There are few, named months, so a step
2228
2377
  // enumerates them ("January, April, July, and October") rather than reading as
2229
2378
  // a frequency — except interval 2, which reads as "every odd/even-numbered
2230
2379
  // month".
2231
- function monthName(ir: IR, opts: NormalizedOptions): string {
2232
- const oddEven = oddEvenMonth(ir.pattern.month);
2380
+ function monthName(schedule: Schedule, opts: NormalizedOptions): string {
2381
+ const oddEven = oddEvenMonth(schedule.pattern.month);
2233
2382
 
2234
2383
  if (oddEven) {
2235
2384
  return oddEven;
@@ -2237,7 +2386,7 @@ function monthName(ir: IR, opts: NormalizedOptions): string {
2237
2386
 
2238
2387
  // A restricted month has segments; open steps of interval 3+ enumerate their
2239
2388
  // fires here too.
2240
- return renderSegments(segmentsOf(ir, 'month'), function name(value) {
2389
+ return renderSegments(segmentsOf(schedule, 'month'), function name(value) {
2241
2390
  return getMonth(value, opts);
2242
2391
  }, opts);
2243
2392
  }
@@ -2271,12 +2420,12 @@ function oddEvenMonth(monthField: string): string | null {
2271
2420
  // keeps the singular idiom ("on Monday through Friday") so its through-
2272
2421
  // connective stays unmistakable, and a leading time-anchored form ("every
2273
2422
  // Monday") is never recurring here.
2274
- function weekdayPhrase(ir: IR, recurring: boolean,
2423
+ function weekdayPhrase(schedule: Schedule, recurring: boolean,
2275
2424
  opts: NormalizedOptions): string {
2276
2425
  // Reached only with a restricted weekday, which has segments. Weekday lists
2277
- // display Monday-first (Sunday last) so a weekend reads naturally; the IR
2278
- // stays canonical (Sunday=0) and ranges keep their form.
2279
- const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
2426
+ // display Monday-first (Sunday last) so a weekend reads naturally; the
2427
+ // Schedule stays canonical (Sunday=0) and ranges keep their form.
2428
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
2280
2429
  const hasRange = segments.some(function range(segment) {
2281
2430
  return segment.kind === 'range';
2282
2431
  });
@@ -2332,9 +2481,9 @@ function renderSegments(segments: Segment[],
2332
2481
 
2333
2482
  // Append or fold the year field into a finished description. An
2334
2483
  // explicitly supplied year is always rendered.
2335
- function applyYear(description: string, ir: IR,
2484
+ function applyYear(description: string, schedule: Schedule,
2336
2485
  opts: NormalizedOptions): string {
2337
- const yearField = ir.pattern.year;
2486
+ const yearField = schedule.pattern.year;
2338
2487
 
2339
2488
  if (yearField === '*') {
2340
2489
  return description;
@@ -2350,7 +2499,7 @@ function applyYear(description: string, ir: IR,
2350
2499
  const label = yearLabel(yearField, opts);
2351
2500
 
2352
2501
  if (yearField.indexOf('-') === -1 && yearField.indexOf(',') === -1 &&
2353
- ir.pattern.date !== '*' && description.indexOf(' at ') !== -1) {
2502
+ schedule.pattern.date !== '*' && description.indexOf(' at ') !== -1) {
2354
2503
  // US dates take a comma before the year ("January 1, 2030"); UK dates
2355
2504
  // do not ("1 January 2030").
2356
2505
  const yearGlue = opts.style.dayFirst ? ' ' : ', ';
@@ -2520,7 +2669,7 @@ function getWeekday(d: number | string, opts: NormalizedOptions): string {
2520
2669
  return (weekday && weekday[opts.short ? 1 : 0]) as string;
2521
2670
  }
2522
2671
 
2523
- // The English language module: the IR renderer plus the language-owned
2672
+ // The English language module: the Schedule renderer plus the language-owned
2524
2673
  // strings and option normalization.
2525
2674
  const en: Language = {
2526
2675
  describe,