cronli5 0.2.0 → 0.3.1

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