cronli5 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/cronli5.min.js +2 -2
  3. package/dist/cronli5.cjs +471 -383
  4. package/dist/cronli5.js +471 -383
  5. package/dist/lang/de.cjs +286 -215
  6. package/dist/lang/de.js +286 -215
  7. package/dist/lang/en.cjs +413 -327
  8. package/dist/lang/en.js +413 -327
  9. package/dist/lang/es.cjs +303 -265
  10. package/dist/lang/es.js +303 -265
  11. package/dist/lang/fi.cjs +311 -266
  12. package/dist/lang/fi.js +311 -266
  13. package/dist/lang/zh.cjs +308 -236
  14. package/dist/lang/zh.js +308 -236
  15. package/package.json +1 -1
  16. package/src/core/analyze.ts +12 -12
  17. package/src/core/cadence.ts +164 -0
  18. package/src/core/index.ts +3 -1
  19. package/src/core/normalize.ts +3 -3
  20. package/src/core/parse.ts +1 -1
  21. package/src/core/{ir.ts → schedule.ts} +17 -18
  22. package/src/core/specs.ts +1 -1
  23. package/src/core/util.ts +3 -165
  24. package/src/core/validate.ts +1 -1
  25. package/src/core/weekday.ts +54 -0
  26. package/src/cronli5.ts +5 -5
  27. package/src/lang/de/index.ts +329 -219
  28. package/src/lang/en/dialects.ts +1 -1
  29. package/src/lang/en/index.ts +521 -372
  30. package/src/lang/es/index.ts +338 -286
  31. package/src/lang/es/notes.md +1 -1
  32. package/src/lang/fi/dialects.ts +1 -1
  33. package/src/lang/fi/index.ts +365 -299
  34. package/src/lang/fi/notes.md +23 -8
  35. package/src/lang/fi/status.json +1 -1
  36. package/src/lang/zh/index.ts +344 -237
  37. package/src/types.ts +6 -6
  38. package/types/core/analyze.d.ts +3 -3
  39. package/types/core/cadence.d.ts +33 -0
  40. package/types/core/index.d.ts +3 -1
  41. package/types/core/normalize.d.ts +1 -1
  42. package/types/core/parse.d.ts +1 -1
  43. package/types/core/{ir.d.ts → schedule.d.ts} +11 -16
  44. package/types/core/specs.d.ts +1 -1
  45. package/types/core/util.d.ts +1 -30
  46. package/types/core/weekday.d.ts +10 -0
  47. package/types/lang/de/index.d.ts +1 -1
  48. package/types/lang/en/dialects.d.ts +1 -1
  49. package/types/lang/en/index.d.ts +1 -1
  50. package/types/lang/es/index.d.ts +1 -1
  51. package/types/lang/fi/dialects.d.ts +1 -1
  52. package/types/lang/fi/index.d.ts +1 -1
  53. package/types/lang/zh/index.d.ts +1 -1
  54. package/types/types.d.ts +5 -5
@@ -1,9 +1,9 @@
1
- // The Spanish language module: renders an analyzed cron pattern (the IR
1
+ // The Spanish language module: renders an analyzed cron pattern (the Schedule
2
2
  // produced by core `analyze`) as natural Spanish. Anchored to RAE/DPD and
3
3
  // FundéuRAE conventions; see notes.md for the decisions and trade-offs.
4
4
  //
5
5
  // Spanish is the pilot language for the i18n architecture
6
- // (docs/i18n-design.md §7): it consumes only the IR, owns all of its
6
+ // (docs/i18n-design.md §7): it consumes only the Schedule, owns all of its
7
7
  // words, and is free to re-plan where Spanish grammar prefers a
8
8
  // different shape than the plan hint (e.g. wildcard minutes over hour
9
9
  // lists render as per-hour windows).
@@ -12,21 +12,23 @@ import {clockDigits, numeral} from '../../core/format.js';
12
12
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
13
13
  import {isOpenStep} from '../../core/shapes.js';
14
14
  import {
15
- arithmeticStep, hourListStride, offsetCleanStride, orderWeekdaysForDisplay,
16
- segmentsOf, singleValues, stepSegment, toFieldNumber
17
- } from '../../core/util.js';
15
+ arithmeticStep, hourListStride, offsetCleanStride,
16
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
17
+ } from '../../core/cadence.js';
18
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
19
+ import {toFieldNumber} from '../../core/util.js';
18
20
  import type {Cronli5Options} from '../../types.js';
19
21
  import type {
20
- HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
22
+ HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
21
23
  Segment
22
- } from '../../core/ir.js';
24
+ } from '../../core/schedule.js';
23
25
  import {resolveDialect, type SpanishStyle} from './dialects.js';
24
26
 
25
27
  // Normalized options carrying Spanish's own style shape.
26
28
  type Opts = NormalizedOptions<SpanishStyle>;
27
29
 
28
30
  // The erased renderer signature the dispatch table maps to.
29
- type Renderer = (ir: IR, plan: PlanNode, opts: Opts) => string;
31
+ type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
30
32
 
31
33
  // A `step` segment, narrowed from the discriminated `Segment` union.
32
34
  type StepSegment = Extract<Segment, {kind: 'step'}>;
@@ -130,22 +132,22 @@ function normalizeOptions(options?: Cronli5Options): Opts {
130
132
  };
131
133
  }
132
134
 
133
- // Render an analyzed cron pattern (the IR) as Spanish.
134
- function describe(ir: IR, opts: Opts): string {
135
- return applyYear(render(ir, ir.plan, opts), ir, opts);
135
+ // Render an analyzed cron pattern (the Schedule) as Spanish.
136
+ function describe(schedule: Schedule, opts: Opts): string {
137
+ return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
136
138
  }
137
139
 
138
140
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
139
141
  // When BOTH date and weekday are restricted (a date-OR-weekday union), the
140
142
  // result is wrapped in the unified `[month] [time], ya sea <DOM> o <DOW>`
141
143
  // frame regardless of arm shapes or month type.
142
- function render(ir: IR, plan: PlanNode, opts: Opts): string {
144
+ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
143
145
  // Each renderer narrows `plan` to its own `kind`; the dispatch table is
144
146
  // keyed by that discriminant, so the union-to-specific match is sound but
145
147
  // not expressible without a cast.
146
- const phrase = (renderers[plan.kind] as Renderer)(ir, plan, opts);
148
+ const phrase = (renderers[plan.kind] as Renderer)(schedule, plan, opts);
147
149
 
148
- if (!isDateWeekdayUnion(ir)) {
150
+ if (!isDateWeekdayUnion(schedule)) {
149
151
  return phrase;
150
152
  }
151
153
 
@@ -153,55 +155,56 @@ function render(ir: IR, plan: PlanNode, opts: Opts): string {
153
155
  // (leadingQualifier and trailingQualifier both return '' for union patterns).
154
156
  // Front the shared month (possibly with a trailing comma for enumerations),
155
157
  // then append the union correlative last.
156
- const lead = unionMonthLeadFull(ir);
158
+ const lead = unionMonthLeadFull(schedule);
157
159
 
158
- return (lead ? lead + ' ' : '') + phrase + unionYaseaSuffix(ir, opts);
160
+ return (lead ? lead + ' ' : '') + phrase + unionYaseaSuffix(schedule, opts);
159
161
  }
160
162
 
161
163
  // --- Seconds renderers. ---
162
164
 
163
165
  function renderEverySecond(
164
- ir: IR,
166
+ schedule: Schedule,
165
167
  plan: Extract<PlanNode, {kind: 'everySecond'}>,
166
168
  opts: Opts
167
169
  ): string {
168
- return 'cada segundo' + trailingQualifier(ir, opts);
170
+ return 'cada segundo' + trailingQualifier(schedule, opts);
169
171
  }
170
172
 
171
173
  function renderStandaloneSeconds(
172
- ir: IR,
174
+ schedule: Schedule,
173
175
  plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
174
176
  opts: Opts
175
177
  ): string {
176
- return secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
178
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
177
179
  }
178
180
 
179
181
  function renderSecondPastMinute(
180
- ir: IR,
182
+ schedule: Schedule,
181
183
  plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
182
184
  opts: Opts
183
185
  ): string {
184
- return 'en el segundo ' + ir.pattern.second + ' de cada minuto' +
185
- trailingQualifier(ir, opts);
186
+ return 'en el segundo ' + schedule.pattern.second + ' de cada minuto' +
187
+ trailingQualifier(schedule, opts);
186
188
  }
187
189
 
188
190
  // A meaningful second combined with a single specific minute (and an open
189
191
  // hour): a single second folds into the minute anchor; a list, range, or
190
192
  // step leads with its own clause.
191
193
  function renderSecondsWithinMinute(
192
- ir: IR,
194
+ schedule: Schedule,
193
195
  plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
194
196
  opts: Opts
195
197
  ): string {
196
- const minuteField = ir.pattern.minute;
198
+ const minuteField = schedule.pattern.minute;
197
199
 
198
200
  if (plan.singleSecond) {
199
201
  return 'en el minuto ' + minuteField + ' y el segundo ' +
200
- ir.pattern.second + ' de cada hora' + trailingQualifier(ir, opts);
202
+ schedule.pattern.second + ' de cada hora' +
203
+ trailingQualifier(schedule, opts);
201
204
  }
202
205
 
203
- return secondsLeadClause(ir, opts) + ', en el minuto ' + minuteField +
204
- ' de cada hora' + trailingQualifier(ir, opts);
206
+ return secondsLeadClause(schedule, opts) + ', en el minuto ' + minuteField +
207
+ ' de cada hora' + trailingQualifier(schedule, opts);
205
208
  }
206
209
 
207
210
  // A seconds list nested into one or more fixed clock times ("..., en los
@@ -210,7 +213,7 @@ function renderSecondsWithinMinute(
210
213
  // are listed. The clock time follows with the genitive "de", so the stride
211
214
  // drops its "de cada minuto" anchor.
212
215
  function secondsListAtClock(
213
- ir: IR,
216
+ schedule: Schedule,
214
217
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
215
218
  opts: Opts
216
219
  ): string {
@@ -222,10 +225,10 @@ function secondsListAtClock(
222
225
  // prepend "de " to produce the genitive form "de las 09:00 y 17:00".
223
226
  const clockList = grouped.startsWith('a ') ? grouped.slice(2) : grouped;
224
227
  const stride =
225
- strideFromSegments(segmentsOf(ir, 'second'), 'segundo', '', opts);
228
+ strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', '', opts);
226
229
  const secondsPhrase = stride ?? 'en los segundos ' +
227
- joinList(segmentWords(segmentsOf(ir, 'second')));
228
- const dayFrame = trailingQualifier(ir, opts);
230
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
231
+ const dayFrame = trailingQualifier(schedule, opts);
229
232
 
230
233
  return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
231
234
  secondsPhrase + ' de ' + clockList;
@@ -236,35 +239,37 @@ function secondsListAtClock(
236
239
  // when that does not apply (a non-clock rest, a multi-valued minute, or an
237
240
  // hour that is not a stride).
238
241
  function composeHourCadence(
239
- ir: IR,
242
+ schedule: Schedule,
240
243
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
241
244
  opts: Opts
242
245
  ): string | null {
243
246
  const clockRest = plan.rest.kind === 'clockTimes' ||
244
247
  plan.rest.kind === 'compactClockTimes';
245
248
 
246
- if (!clockRest || ir.shapes.minute !== 'single') {
249
+ if (!clockRest || schedule.shapes.minute !== 'single') {
247
250
  return null;
248
251
  }
249
252
 
250
- const minute = +ir.pattern.minute;
253
+ const minute = +schedule.pattern.minute;
251
254
 
252
- return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
255
+ return hourCadence(schedule, minute, opts) ??
256
+ hourRangeCadence(schedule, minute, opts);
253
257
  }
254
258
 
255
259
  // A wildcard or stepped second with a fixed minute across one or more specific
256
260
  // hours: the seconds confine to the clock time(s), each minute named.
257
261
  function isPinnedMinuteSeconds(
258
- ir: IR,
262
+ schedule: Schedule,
259
263
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
260
264
  ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
261
265
  {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
262
266
  return plan.rest.kind === 'clockTimes' &&
263
- (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step');
267
+ (schedule.shapes.second === 'wildcard' ||
268
+ schedule.shapes.second === 'step');
264
269
  }
265
270
 
266
271
  function renderComposeSeconds(
267
- ir: IR,
272
+ schedule: Schedule,
268
273
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
269
274
  opts: Opts
270
275
  ): string {
@@ -272,7 +277,7 @@ function renderComposeSeconds(
272
277
  // minute is a cadence, not a wall of clock times: the second/minute lead,
273
278
  // then the hour cadence ("en el segundo 30 de cada hora, cada dos horas").
274
279
  // The clock-time rest would otherwise cross-multiply the hours.
275
- const hourCad = composeHourCadence(ir, plan, opts);
280
+ const hourCad = composeHourCadence(schedule, plan, opts);
276
281
 
277
282
  if (hourCad !== null) {
278
283
  return hourCad;
@@ -280,28 +285,28 @@ function renderComposeSeconds(
280
285
 
281
286
  // A wildcard or stepped second with the minute pinned to a single value
282
287
  // across one or more specific hours: the seconds confine to the clock time.
283
- if (isPinnedMinuteSeconds(ir, plan)) {
284
- return pinnedMinuteSeconds(ir, plan.rest, opts);
288
+ if (isPinnedMinuteSeconds(schedule, plan)) {
289
+ return pinnedMinuteSeconds(schedule, plan.rest, opts);
285
290
  }
286
291
 
287
292
  // Seconds list + fixed clock time: nest the seconds into the clock time(s)
288
293
  // with genitive "de las HH:MM" instead of "de cada minuto"; the minute is
289
294
  // fixed so "de cada minuto" is misleading. Single seconds already fold into
290
295
  // the time in the clockTimes renderer; step seconds keep their own clause.
291
- if (plan.rest.kind === 'clockTimes' && ir.shapes.second === 'list') {
292
- return secondsListAtClock(ir, plan.rest, opts);
296
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.second === 'list') {
297
+ return secondsListAtClock(schedule, plan.rest, opts);
293
298
  }
294
299
 
295
300
  // Second-step + fixed minute + hour range + weekday: anchor the cadence to
296
301
  // the minute after the weekday + hour-range frame.
297
- if (plan.rest.kind === 'hourRange' && ir.shapes.second === 'step' &&
298
- ir.pattern.weekday !== '*') {
302
+ if (plan.rest.kind === 'hourRange' && schedule.shapes.second === 'step' &&
303
+ schedule.pattern.weekday !== '*') {
299
304
  const restNode = plan.rest;
300
305
  const window = hourWindow(boundedWindow(restNode), opts);
301
- const dayFrame = weekdayQualifier(ir) + monthScope(ir);
306
+ const dayFrame = weekdayQualifier(schedule) + monthScope(schedule);
302
307
  const cadence = 'cada ' +
303
- numero(stepSegment(ir, 'second').interval, opts) +
304
- ' segundos del minuto ' + ir.pattern.minute;
308
+ numero(stepSegment(schedule, 'second').interval, opts) +
309
+ ' segundos del minuto ' + schedule.pattern.minute;
305
310
 
306
311
  return dayFrame + ', ' + window + ', ' + cadence;
307
312
  }
@@ -311,8 +316,9 @@ function renderComposeSeconds(
311
316
  // Bind them with the genitive "de" ("cada segundo de cada dos minutos"),
312
317
  // mirroring English. Other strides, a restricted hour, and an hour cadence
313
318
  // keep the juxtaposed form.
314
- if (isEveryOtherMinuteSeconds(ir, plan)) {
315
- return secondsLeadClause(ir, opts) + ' de ' + render(ir, plan.rest, opts);
319
+ if (isEveryOtherMinuteSeconds(schedule, plan)) {
320
+ return secondsLeadClause(schedule, opts) + ' de ' +
321
+ render(schedule, plan.rest, opts);
316
322
  }
317
323
 
318
324
  // A compact clock-time rest folds a meaningful SINGLE second into its own
@@ -320,24 +326,25 @@ function renderComposeSeconds(
320
326
  // double it. A wildcard or stepped second is not folded there (no
321
327
  // clockSecond), so it still leads its own clause here.
322
328
  const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
323
- ir.analyses.clockSecond;
324
- const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
329
+ schedule.analyses.clockSecond;
330
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
325
331
 
326
- return lead + render(ir, plan.rest, opts);
332
+ return lead + render(schedule, plan.rest, opts);
327
333
  }
328
334
 
329
335
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
330
336
  // cadences read as contradictory side by side, so they bind into one.
331
337
  function isEveryOtherMinuteSeconds(
332
- ir: IR,
338
+ schedule: Schedule,
333
339
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
334
340
  ): boolean {
335
341
  if (plan.rest.kind !== 'minuteFrequency' ||
336
- ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
342
+ schedule.shapes.second !== 'wildcard' ||
343
+ schedule.shapes.hour !== 'wildcard') {
337
344
  return false;
338
345
  }
339
346
 
340
- const minuteStep = stepSegment(ir, 'minute');
347
+ const minuteStep = stepSegment(schedule, 'minute');
341
348
 
342
349
  return minuteStep.startToken === '*' && minuteStep.interval === 2;
343
350
  }
@@ -353,11 +360,11 @@ function isEveryOtherMinuteSeconds(
353
360
  // minute is an unambiguous clock time, so the genitive "de las 09:05" form
354
361
  // reads it as the minute, never the hour.
355
362
  function pinnedMinuteSeconds(
356
- ir: IR,
363
+ schedule: Schedule,
357
364
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
358
365
  opts: Opts
359
366
  ): string {
360
- const dayTrail = leadingQualifier(ir, opts).trimEnd();
367
+ const dayTrail = leadingQualifier(schedule, opts).trimEnd();
361
368
  const trail = dayTrail ? ', ' + dayTrail : '';
362
369
 
363
370
  // The "durante un minuto a las 9" duration form drops the clock minute, so it
@@ -365,18 +372,18 @@ function pinnedMinuteSeconds(
365
372
  // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
366
373
  // minute, never collapse to the bare hour (which once repeated it, "a las 9 y
367
374
  // 9"), so it takes the explicit clock list.
368
- if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
369
- return secondsLeadClause(ir, opts) + ' durante un minuto ' +
375
+ if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
376
+ return secondsLeadClause(schedule, opts) + ' durante un minuto ' +
370
377
  durationHourList(rest.times, opts) + trail;
371
378
  }
372
379
 
373
- return secondsLeadClause(ir, opts) + ' de ' +
380
+ return secondsLeadClause(schedule, opts) + ' de ' +
374
381
  explicitClockList(rest.times, opts) + trail;
375
382
  }
376
383
 
377
384
  // The leading clause describing a second field relative to the minute.
378
- function secondsLeadClause(ir: IR, opts: Opts): string {
379
- return secondsClause(ir, 'minuto', opts);
385
+ function secondsLeadClause(schedule: Schedule, opts: Opts): string {
386
+ return secondsClause(schedule, 'minuto', opts);
380
387
  }
381
388
 
382
389
  // The second clause counted against an arbitrary anchor. The anchor is
@@ -384,16 +391,16 @@ function secondsLeadClause(ir: IR, opts: Opts): string {
384
391
  // pinned minute 0 into the hour and counts the second "de cada hora" instead
385
392
  // ("en el segundo 30 de cada hora"), so the minute-0 confinement is stated,
386
393
  // not dropped.
387
- function secondsClause(ir: IR, anchor: string, opts: Opts): string {
388
- const secondField = ir.pattern.second;
389
- const shape = ir.shapes.second;
394
+ function secondsClause(schedule: Schedule, anchor: string, opts: Opts): string {
395
+ const secondField = schedule.pattern.second;
396
+ const shape = schedule.shapes.second;
390
397
 
391
398
  if (secondField === '*') {
392
399
  return 'cada segundo';
393
400
  }
394
401
 
395
402
  if (shape === 'step') {
396
- return stepCycle60(stepSegment(ir, 'second'), 'segundo',
403
+ return stepCycle60(stepSegment(schedule, 'second'), 'segundo',
397
404
  anchor, opts);
398
405
  }
399
406
 
@@ -408,55 +415,55 @@ function secondsClause(ir: IR, anchor: string, opts: Opts): string {
408
415
  return 'en el segundo ' + secondField + ' de cada ' + anchor;
409
416
  }
410
417
 
411
- return strideFromSegments(segmentsOf(ir, 'second'), 'segundo', anchor,
418
+ return strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', anchor,
412
419
  opts) ?? 'en los segundos ' +
413
- joinList(segmentWords(segmentsOf(ir, 'second'))) +
420
+ joinList(segmentWords(segmentsOf(schedule, 'second'))) +
414
421
  ' de cada ' + anchor;
415
422
  }
416
423
 
417
424
  // --- Minute renderers. ---
418
425
 
419
426
  function renderEveryMinute(
420
- ir: IR,
427
+ schedule: Schedule,
421
428
  plan: Extract<PlanNode, {kind: 'everyMinute'}>,
422
429
  opts: Opts
423
430
  ): string {
424
- return 'cada minuto' + trailingQualifier(ir, opts);
431
+ return 'cada minuto' + trailingQualifier(schedule, opts);
425
432
  }
426
433
 
427
434
  function renderSingleMinute(
428
- ir: IR,
435
+ schedule: Schedule,
429
436
  plan: Extract<PlanNode, {kind: 'singleMinute'}>,
430
437
  opts: Opts
431
438
  ): string {
432
- return 'en el minuto ' + ir.pattern.minute + ' de cada hora' +
433
- trailingQualifier(ir, opts);
439
+ return 'en el minuto ' + schedule.pattern.minute + ' de cada hora' +
440
+ trailingQualifier(schedule, opts);
434
441
  }
435
442
 
436
443
  function renderRangeOfMinutes(
437
- ir: IR,
444
+ schedule: Schedule,
438
445
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
439
446
  opts: Opts
440
447
  ): string {
441
- return minuteRangeLead(ir.pattern.minute) + ' de cada hora' +
442
- trailingQualifier(ir, opts);
448
+ return minuteRangeLead(schedule.pattern.minute) + ' de cada hora' +
449
+ trailingQualifier(schedule, opts);
443
450
  }
444
451
 
445
452
  function renderMultipleMinutes(
446
- ir: IR,
453
+ schedule: Schedule,
447
454
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
448
455
  opts: Opts
449
456
  ): string {
450
- return minutesList(ir, opts) + trailingQualifier(ir, opts);
457
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
451
458
  }
452
459
 
453
460
  // "en los minutos 5, 10 y 30 de cada hora". An offset/uneven step the core
454
461
  // enumerated to this list reads as a stride cadence when the fires form a
455
462
  // long-enough progression.
456
- function minutesList(ir: IR, opts: Opts): string {
457
- return strideFromSegments(segmentsOf(ir, 'minute'), 'minuto', 'hora',
463
+ function minutesList(schedule: Schedule, opts: Opts): string {
464
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minuto', 'hora',
458
465
  opts) ?? 'en los minutos ' +
459
- joinList(segmentWords(segmentsOf(ir, 'minute'))) + ' de cada hora';
466
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
460
467
  }
461
468
 
462
469
  // "cada minuto del 0 al 30". The standalone renderer adds "de cada hora";
@@ -558,17 +565,17 @@ function spanHours(hours: number[]): string {
558
565
 
559
566
  // A repeating minute step, qualified by the active hour window(s).
560
567
  function renderMinuteFrequency(
561
- ir: IR,
568
+ schedule: Schedule,
562
569
  plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
563
570
  opts: Opts
564
571
  ): string {
565
- let phrase = stepCycle60(stepSegment(ir, 'minute'), 'minuto',
572
+ let phrase = stepCycle60(stepSegment(schedule, 'minute'), 'minuto',
566
573
  'hora', opts);
567
574
 
568
575
  if (plan.hours.kind === 'during') {
569
576
  // A uneven hour stride confines the minute cadence to its own bounded hour
570
577
  // cadence ("cada 15 minutos, cada cinco horas de las 00:00 a las 20:00").
571
- const cadence = unevenHourCadence(ir, opts);
578
+ const cadence = unevenHourCadence(schedule, opts);
572
579
 
573
580
  if (cadence) {
574
581
  phrase += ', ' + cadence;
@@ -576,9 +583,9 @@ function renderMinuteFrequency(
576
583
  else {
577
584
  // An offset step (e.g. 1/2) arrives here; a single step reads as a
578
585
  // confinement, not the verbose window list.
579
- phrase += singleHourStep(ir.analyses.segments.hour) ?
580
- ', ' + stepHourSpan(stepSegment(ir, 'hour'), opts) :
581
- ' ' + hourSpanFromTimes(ir, plan.hours.times, opts);
586
+ phrase += singleHourStep(schedule.analyses.segments.hour) ?
587
+ ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts) :
588
+ ' ' + hourSpanFromTimes(schedule, plan.hours.times, opts);
582
589
  }
583
590
  }
584
591
  else if (plan.hours.kind === 'window') {
@@ -587,10 +594,10 @@ function renderMinuteFrequency(
587
594
  else if (plan.hours.kind === 'step') {
588
595
  // A clean stride is a confinement ("las horas pares", or the active-hour
589
596
  // list), never a juxtaposed cadence ("cada dos horas").
590
- phrase += ', ' + stepHourSpan(stepSegment(ir, 'hour'), opts);
597
+ phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
591
598
  }
592
599
 
593
- return phrase + trailingQualifier(ir, opts);
600
+ return phrase + trailingQualifier(schedule, opts);
594
601
  }
595
602
 
596
603
  // "cada minuto de las 9:00 a las 9:29 de la mañana". A wildcard minute is the
@@ -598,144 +605,144 @@ function renderMinuteFrequency(
598
605
  // 09:00") rather than a synthesized "de las HH:00 a las HH:59" range the
599
606
  // source never stated; a plain range is a real window and keeps "de … a …".
600
607
  function renderMinuteSpanInHour(
601
- ir: IR,
608
+ schedule: Schedule,
602
609
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
603
610
  opts: Opts
604
611
  ): string {
605
- if (ir.pattern.minute === '*') {
612
+ if (schedule.pattern.minute === '*') {
606
613
  return 'cada minuto de la hora ' +
607
614
  fromTime(timePhrase(plan.hour, 0, null, opts)) +
608
- trailingQualifier(ir, opts);
615
+ trailingQualifier(schedule, opts);
609
616
  }
610
617
 
611
618
  return 'cada minuto ' +
612
619
  timeRange({hour: plan.hour, minute: plan.span[0]},
613
620
  {hour: plan.hour, minute: plan.span[1]}, opts) +
614
- trailingQualifier(ir, opts);
621
+ trailingQualifier(schedule, opts);
615
622
  }
616
623
 
617
624
  // A minute window under discrete hours. Spanish re-plans the
618
625
  // wildcard form: rather than "during the X hours", each hour reads as its
619
626
  // own window ("de las 9:00 a las 9:59").
620
627
  function renderMinutesAcrossHours(
621
- ir: IR,
628
+ schedule: Schedule,
622
629
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
623
630
  opts: Opts
624
631
  ): string {
625
632
  // A uneven hour stride reads as a cadence, not a wall of hour columns: the
626
633
  // minute lead, then "cada N horas de las X a las Y".
627
- const cadence = unevenHourCadence(ir, opts);
634
+ const cadence = unevenHourCadence(schedule, opts);
628
635
 
629
636
  if (plan.form === 'wildcard') {
630
637
  if (cadence !== null) {
631
- return 'cada minuto, ' + cadence + trailingQualifier(ir, opts);
638
+ return 'cada minuto, ' + cadence + trailingQualifier(schedule, opts);
632
639
  }
633
640
 
634
- if (singleHourStep(ir.analyses.segments.hour)) {
641
+ if (singleHourStep(schedule.analyses.segments.hour)) {
635
642
  return 'cada minuto, ' +
636
- stepHourSpan(stepSegment(ir, 'hour'), opts) +
637
- trailingQualifier(ir, opts);
643
+ stepHourSpan(stepSegment(schedule, 'hour'), opts) +
644
+ trailingQualifier(schedule, opts);
638
645
  }
639
646
 
640
- return 'cada minuto ' + hourSpanFromTimes(ir, plan.times, opts) +
641
- trailingQualifier(ir, opts);
647
+ return 'cada minuto ' + hourSpanFromTimes(schedule, plan.times, opts) +
648
+ trailingQualifier(schedule, opts);
642
649
  }
643
650
 
644
651
  const lead = plan.form === 'range' ?
645
- minuteRangeLead(ir.pattern.minute) :
646
- minutesList(ir, opts);
652
+ minuteRangeLead(schedule.pattern.minute) :
653
+ minutesList(schedule, opts);
647
654
 
648
655
  if (cadence !== null) {
649
- return lead + ', ' + cadence + trailingQualifier(ir, opts);
656
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
650
657
  }
651
658
 
652
- return lead + ', ' + atHourTimes(ir, plan.times, opts) +
653
- trailingQualifier(ir, opts);
659
+ return lead + ', ' + atHourTimes(schedule, plan.times, opts) +
660
+ trailingQualifier(schedule, opts);
654
661
  }
655
662
 
656
663
  function renderMinuteSpanAcrossHourStep(
657
- ir: IR,
664
+ schedule: Schedule,
658
665
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
659
666
  opts: Opts
660
667
  ): string {
661
- const segment = stepSegment(ir, 'hour');
668
+ const segment = stepSegment(schedule, 'hour');
662
669
  // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
663
670
  // offset-clean step keeps its confinement / per-step phrasing.
664
- const cadence = unevenHourCadence(ir, opts);
671
+ const cadence = unevenHourCadence(schedule, opts);
665
672
 
666
673
  // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
667
674
  // or uneven step routes through minutesAcrossHours instead) and is confined.
668
675
  if (plan.form === 'wildcard') {
669
676
  return 'cada minuto, ' + stepHourSpan(segment, opts) +
670
- trailingQualifier(ir, opts);
677
+ trailingQualifier(schedule, opts);
671
678
  }
672
679
 
673
680
  // A minute list keeps the same cadence clause as the range; only its lead
674
681
  // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
675
682
  // 30").
676
683
  const lead = plan.form === 'list' ?
677
- minutesList(ir, opts) :
678
- minuteRangeLead(ir.pattern.minute);
684
+ minutesList(schedule, opts) :
685
+ minuteRangeLead(schedule.pattern.minute);
679
686
 
680
687
  return lead + ', ' +
681
- (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
688
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
682
689
  }
683
690
 
684
691
  // --- Hour renderers. ---
685
692
 
686
693
  function renderEveryHour(
687
- ir: IR,
694
+ schedule: Schedule,
688
695
  plan: Extract<PlanNode, {kind: 'everyHour'}>,
689
696
  opts: Opts
690
697
  ): string {
691
- return 'cada hora' + trailingQualifier(ir, opts);
698
+ return 'cada hora' + trailingQualifier(schedule, opts);
692
699
  }
693
700
 
694
701
  function renderHourRange(
695
- ir: IR,
702
+ schedule: Schedule,
696
703
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
697
704
  opts: Opts
698
705
  ): string {
699
706
  const window = hourWindow(boundedWindow(plan), opts);
700
707
 
701
708
  if (plan.minuteForm === 'wildcard') {
702
- return 'cada minuto ' + window + trailingQualifier(ir, opts);
709
+ return 'cada minuto ' + window + trailingQualifier(schedule, opts);
703
710
  }
704
711
 
705
712
  if (plan.minuteForm === 'range') {
706
- return minuteRangeLead(ir.pattern.minute) + ', ' + window +
707
- trailingQualifier(ir, opts);
713
+ return minuteRangeLead(schedule.pattern.minute) + ', ' + window +
714
+ trailingQualifier(schedule, opts);
708
715
  }
709
716
 
710
717
  // On the hour the window joins directly ("cada hora de las 9:00 a las
711
718
  // 17:00"); a discrete minute anchors its own clause first.
712
- if (ir.pattern.minute === '0') {
713
- return 'cada hora ' + window + trailingQualifier(ir, opts);
719
+ if (schedule.pattern.minute === '0') {
720
+ return 'cada hora ' + window + trailingQualifier(schedule, opts);
714
721
  }
715
722
 
716
- const lead = ir.shapes.minute === 'single' ?
717
- 'en el minuto ' + ir.pattern.minute + ' de cada hora' :
718
- minutesList(ir, opts);
723
+ const lead = schedule.shapes.minute === 'single' ?
724
+ 'en el minuto ' + schedule.pattern.minute + ' de cada hora' :
725
+ minutesList(schedule, opts);
719
726
 
720
- return lead + ', ' + window + trailingQualifier(ir, opts);
727
+ return lead + ', ' + window + trailingQualifier(schedule, opts);
721
728
  }
722
729
 
723
730
  function renderHourStep(
724
- ir: IR,
731
+ schedule: Schedule,
725
732
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
726
733
  opts: Opts
727
734
  ): string {
728
735
  // A bounded or uneven hour step reads as its endpoint-pinning cadence ("cada
729
736
  // dos horas de las 09:00 a las 17:00"); an offset-clean step keeps its bare
730
737
  // or "a partir de" cadence.
731
- const cadence = unevenHourCadence(ir, opts);
738
+ const cadence = unevenHourCadence(schedule, opts);
732
739
 
733
740
  if (cadence !== null) {
734
- return cadence + trailingQualifier(ir, opts);
741
+ return cadence + trailingQualifier(schedule, opts);
735
742
  }
736
743
 
737
- return stepHours(stepSegment(ir, 'hour'), opts) +
738
- trailingQualifier(ir, opts);
744
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
745
+ trailingQualifier(schedule, opts);
739
746
  }
740
747
 
741
748
  // The hour-range plan as a window. The close lands on the top of the final
@@ -764,8 +771,8 @@ function hourWindow(
764
771
 
765
772
  // Whether BOTH the date and weekday fields are restricted (not '*'): cron
766
773
  // fires when either condition matches, making this a date-OR-weekday union.
767
- function isDateWeekdayUnion(ir: IR): boolean {
768
- return ir.pattern.date !== '*' && ir.pattern.weekday !== '*';
774
+ function isDateWeekdayUnion(schedule: Schedule): boolean {
775
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
769
776
  }
770
777
 
771
778
  // The month lead for the unified union frame, with a trailing comma appended
@@ -773,14 +780,14 @@ function isDateWeekdayUnion(ir: IR): boolean {
773
780
  // Single month → `en enero`; range → `de enero a marzo`;
774
781
  // step/enumeration (≥2 flattened singles) → `en enero, marzo, …, y noviembre,`.
775
782
  // Wildcard month → '' (omit; frame starts with the time).
776
- function unionMonthLeadFull(ir: IR): string {
777
- if (ir.pattern.month === '*') {
783
+ function unionMonthLeadFull(schedule: Schedule): string {
784
+ if (schedule.pattern.month === '*') {
778
785
  return '';
779
786
  }
780
787
 
781
- const lead = monthPhrase(ir, monthRanged(ir) ? 'de ' : 'en ');
782
- const segments = flattenSteps(segmentsOf(ir, 'month'));
783
- const isEnumeration = !monthRanged(ir) && segments.length >= 2;
788
+ const lead = monthPhrase(schedule, monthRanged(schedule) ? 'de ' : 'en ');
789
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
790
+ const isEnumeration = !monthRanged(schedule) && segments.length >= 2;
784
791
 
785
792
  return isEnumeration ? lead + ',' : lead;
786
793
  }
@@ -789,19 +796,30 @@ function unionMonthLeadFull(ir: IR): string {
789
796
  // Quartz and open-step forms are self-contained; ranges use `del N al M del
790
797
  // mes`; a single date reads `el día N` under a restricted month (month is in
791
798
  // the lead) or `el N de cada mes` under a wildcard month.
792
- function domArm(ir: IR, opts: Opts): string {
793
- const date = ir.pattern.date;
799
+ function domArm(schedule: Schedule, opts: Opts): string {
800
+ const date = schedule.pattern.date;
794
801
  const quartz = quartzDatePhrase(date);
795
802
 
796
803
  if (quartz) {
797
804
  return quartz;
798
805
  }
799
806
 
807
+ // In the union the `*/2` day-of-month is a parity predicate over the days of
808
+ // the month ("un día impar del mes" = 1, 3, …, 31, resetting each month),
809
+ // not the durative "cada dos días del mes" the standalone form uses. A bare
810
+ // "cada dos días" would mis-imply a continuous every-other-day cadence with
811
+ // no monthly anchor, so the reader could not reconstruct the odd days.
812
+ const parity = parityDayPredicate(date);
813
+
814
+ if (parity) {
815
+ return parity;
816
+ }
817
+
800
818
  if (isOpenStep(date)) {
801
819
  return stepDates(date, opts);
802
820
  }
803
821
 
804
- const segments = segmentsOf(ir, 'date');
822
+ const segments = segmentsOf(schedule, 'date');
805
823
 
806
824
  if (segments.length === 1 && segments[0].kind === 'range') {
807
825
  return 'del ' + segments[0].bounds[0] + ' al ' +
@@ -809,7 +827,7 @@ function domArm(ir: IR, opts: Opts): string {
809
827
  }
810
828
 
811
829
  if (segments.length === 1 && segments[0].kind === 'single') {
812
- return ir.pattern.month === '*' ?
830
+ return schedule.pattern.month === '*' ?
813
831
  'el ' + segments[0].value + ' de cada mes' :
814
832
  'el día ' + segments[0].value;
815
833
  }
@@ -821,16 +839,16 @@ function domArm(ir: IR, opts: Opts): string {
821
839
  // Quartz forms are self-contained; a single weekday reads `cualquier <name>`;
822
840
  // all other forms use the same phrasing as the standalone weekday qualifier
823
841
  // (range → `de lunes a viernes`; list/step → `los domingos, …`).
824
- function dowArm(ir: IR): string {
825
- const quartz = quartzWeekdayPhrase(ir.pattern.weekday);
842
+ function dowArm(schedule: Schedule): string {
843
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
826
844
 
827
845
  if (quartz) {
828
846
  return quartz;
829
847
  }
830
848
 
831
849
  // Weekday lists display Monday-first (Sunday last); a lone range keeps its
832
- // form. The IR stays canonical (Sunday=0). The helper flattens steps.
833
- const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
850
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
851
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
834
852
  const allSingles = segments.every(function single(segment) {
835
853
  return segment.kind === 'single';
836
854
  });
@@ -847,8 +865,12 @@ function dowArm(ir: IR): string {
847
865
  }));
848
866
  }
849
867
 
868
+ // A lone weekday range reads "cualquier día de lunes a viernes" in the union:
869
+ // the leading "cualquier día" makes it a day predicate parallel to the
870
+ // date arm ("el 1 de cada mes o cualquier día de lunes a viernes"), so the
871
+ // union "o" plainly joins two independent day conditions.
850
872
  if (segments.length === 1) {
851
- return weekdayRange(segments[0] as RangeNameSegment);
873
+ return 'cualquier día ' + weekdayRange(segments[0] as RangeNameSegment);
852
874
  }
853
875
 
854
876
  return joinList(segments.map(function name(segment) {
@@ -859,23 +881,23 @@ function dowArm(ir: IR): string {
859
881
  }
860
882
 
861
883
  // The `, ya sea <DOM> o <DOW>` correlative suffix for the union frame.
862
- function unionYaseaSuffix(ir: IR, opts: Opts): string {
863
- return ', ya sea ' + domArm(ir, opts) + ' o ' + dowArm(ir);
884
+ function unionYaseaSuffix(schedule: Schedule, opts: Opts): string {
885
+ return ', ya sea ' + domArm(schedule, opts) + ' o ' + dowArm(schedule);
864
886
  }
865
887
 
866
888
  // "todos los días a las 9:30 y a las 17:00".
867
889
  function renderClockTimes(
868
- ir: IR,
890
+ schedule: Schedule,
869
891
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
870
892
  opts: Opts
871
893
  ): string {
872
894
  // An hour step or range (or arithmetic-progression hour list) under a single
873
895
  // pinned minute reads as a cadence or window rather than a cross-product of
874
896
  // clock times.
875
- if (ir.shapes.minute === 'single') {
876
- const minute = +ir.pattern.minute;
877
- const cadence = hourCadence(ir, minute, opts) ??
878
- hourRangeCadence(ir, minute, opts);
897
+ if (schedule.shapes.minute === 'single') {
898
+ const minute = +schedule.pattern.minute;
899
+ const cadence = hourCadence(schedule, minute, opts) ??
900
+ hourRangeCadence(schedule, minute, opts);
879
901
 
880
902
  if (cadence !== null) {
881
903
  return cadence;
@@ -886,7 +908,7 @@ function renderClockTimes(
886
908
  return atTime(timePhrase(time.hour, time.minute, time.second, opts));
887
909
  });
888
910
 
889
- return leadingQualifier(ir, opts) + groupClockTimes(phrases);
911
+ return leadingQualifier(schedule, opts) + groupClockTimes(phrases);
890
912
  }
891
913
 
892
914
  // The genitive clock-time list for a minute-0 compose-seconds confinement:
@@ -1212,7 +1234,7 @@ function groupClockTimesByArticle(phrases: string[]): string {
1212
1234
  // Compact form past the enumeration cap: a single minute folds into
1213
1235
  // per-segment hour windows; a minute list leads with its own clause.
1214
1236
  function renderCompactClockTimes(
1215
- ir: IR,
1237
+ schedule: Schedule,
1216
1238
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
1217
1239
  opts: Opts
1218
1240
  ): string {
@@ -1220,39 +1242,44 @@ function renderCompactClockTimes(
1220
1242
  // An hour step or range (or arithmetic-progression hour list) under the
1221
1243
  // single pinned minute reads as a cadence or window, not a wall of clock
1222
1244
  // times. (Returns null for an irregular list, which keeps folding below.)
1223
- const cadence = hourCadence(ir, plan.minute, opts) ??
1224
- hourRangeCadence(ir, plan.minute, opts);
1245
+ const cadence = hourCadence(schedule, plan.minute, opts) ??
1246
+ hourRangeCadence(schedule, plan.minute, opts);
1225
1247
 
1226
1248
  if (cadence !== null) {
1227
1249
  return cadence;
1228
1250
  }
1229
1251
 
1230
- const ranged = segmentsOf(ir, 'hour').some(function range(segment) {
1252
+ const ranged = segmentsOf(schedule, 'hour').some(function range(segment) {
1231
1253
  return segment.kind === 'range';
1232
1254
  });
1233
1255
 
1234
1256
  // A folded contiguous hour range reads with the hourly cadence ("cada
1235
1257
  // hora de las 9:00 a las 20:00 y a las 22:00"), not "todos los días".
1236
- if (ranged && !ir.analyses.clockSecond) {
1258
+ if (ranged && !schedule.analyses.clockSecond) {
1237
1259
  return 'cada hora ' +
1238
- hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts) +
1239
- trailingQualifier(ir, opts);
1260
+ hourSegmentTimes(
1261
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1262
+ ) +
1263
+ trailingQualifier(schedule, opts);
1240
1264
  }
1241
1265
 
1242
- return leadingQualifier(ir, opts) +
1243
- hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1266
+ return leadingQualifier(schedule, opts) +
1267
+ hourSegmentTimes(
1268
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1269
+ );
1244
1270
  }
1245
1271
 
1246
1272
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1247
1273
  // of clock-time columns.
1248
- const cadence = unevenHourCadence(ir, opts);
1274
+ const cadence = unevenHourCadence(schedule, opts);
1249
1275
  const phrase = cadence ?
1250
- minutesList(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
1251
- minutesList(ir, opts) + ', ' +
1252
- hourContextTimes(ir, opts) + trailingQualifier(ir, opts);
1276
+ minutesList(schedule, opts) + ', ' + cadence +
1277
+ trailingQualifier(schedule, opts) :
1278
+ minutesList(schedule, opts) + ', ' +
1279
+ hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
1253
1280
 
1254
- return ir.analyses.clockSecond ?
1255
- secondsLeadClause(ir, opts) + ', ' + phrase :
1281
+ return schedule.analyses.clockSecond ?
1282
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
1256
1283
  phrase;
1257
1284
  }
1258
1285
 
@@ -1294,21 +1321,17 @@ const renderers = {
1294
1321
  function renderStride(stride: Stride, opts: Opts): string {
1295
1322
  const {interval, start, last, cycle, unit, anchor} = stride;
1296
1323
  const cadence = 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1297
- const tiles = cycle % interval === 0;
1298
-
1299
- if (start === 0 && tiles) {
1300
- return cadence;
1301
- }
1302
1324
 
1303
1325
  // A context that supplies its own trailing scope passes an empty anchor, so
1304
1326
  // the cadence keeps its endpoints but drops the "de cada <anchor>" tail.
1305
1327
  const tail = anchor ? ' de cada ' + anchor : '';
1306
1328
 
1307
- if (start < interval && tiles) {
1308
- return cadence + ' a partir del ' + unit + ' ' + start + tail;
1309
- }
1310
-
1311
- return cadence + ' del ' + unit + ' ' + start + ' al ' + last + tail;
1329
+ return chooseStride({start, interval, cycle}, {
1330
+ bare: () => cadence,
1331
+ offset: () => cadence + ' a partir del ' + unit + ' ' + start + tail,
1332
+ bounded: () =>
1333
+ cadence + ' del ' + unit + ' ' + start + ' al ' + last + tail
1334
+ });
1312
1335
  }
1313
1336
 
1314
1337
  // "cada 15 minutos", "en los minutos 5, 20 y 35 de cada hora", or
@@ -1348,9 +1371,9 @@ function stepCycle60(
1348
1371
 
1349
1372
  // Speak a minute/second field's enumerated fires as a step cadence when they
1350
1373
  // form an arithmetic progression long enough to beat the list (the core
1351
- // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1352
- // the renderer recognizes the progression). Returns null for a non-progression
1353
- // or a too-short list, leaving the caller to enumerate.
1374
+ // enumerates an offset/uneven step to this fire list; the Schedule is
1375
+ // unchanged, so the renderer recognizes the progression). Returns null for a
1376
+ // non-progression or a too-short list, leaving the caller to enumerate.
1354
1377
  function strideFromSegments(
1355
1378
  segments: Segment[],
1356
1379
  unit: string,
@@ -1405,18 +1428,13 @@ function hourStrideCadence(
1405
1428
  ): string {
1406
1429
  const {start, interval, last} = stride;
1407
1430
  const cadence = 'cada ' + numero(interval, opts) + ' horas';
1408
- const tiles = 24 % interval === 0;
1409
-
1410
- if (start === 0 && tiles) {
1411
- return cadence;
1412
- }
1413
1431
 
1414
- if (start < interval && tiles) {
1415
- return cadence + ' a partir de ' + timePhrase(start, 0, null, opts);
1416
- }
1417
-
1418
- return cadence + ' de ' + timePhrase(start, 0, null, opts) + ' a ' +
1419
- timePhrase(last, 0, null, opts);
1432
+ return chooseStride({start, interval, cycle: 24}, {
1433
+ bare: () => cadence,
1434
+ offset: () => cadence + ' a partir de ' + timePhrase(start, 0, null, opts),
1435
+ bounded: () => cadence + ' de ' + timePhrase(start, 0, null, opts) + ' a ' +
1436
+ timePhrase(last, 0, null, opts)
1437
+ });
1420
1438
  }
1421
1439
 
1422
1440
  // The bounded cadence for an hour stride that pins both clock-time endpoints,
@@ -1426,8 +1444,8 @@ function hourStrideCadence(
1426
1444
  // ("…, cada cinco horas de las 00:00 a las 20:00") than as a wall of clock
1427
1445
  // times. An offset-clean stride keeps its existing confinement form, so only
1428
1446
  // the endpoint-bearing case routes here.
1429
- function unevenHourCadence(ir: IR, opts: Opts): string | null {
1430
- const stride = hourStride(ir);
1447
+ function unevenHourCadence(schedule: Schedule, opts: Opts): string | null {
1448
+ const stride = hourStride(schedule);
1431
1449
 
1432
1450
  if (!stride || offsetCleanStride(stride)) {
1433
1451
  return null;
@@ -1439,13 +1457,13 @@ function unevenHourCadence(ir: IR, opts: Opts): string | null {
1439
1457
  // The hour field's stride, or null when the hour is not a cadence: a step
1440
1458
  // segment yields its {start, interval, last} directly; an all-single hour
1441
1459
  // list yields one only when its values form a step progression (so an irregular
1442
- // list like 9,17 keeps enumerating). The IR is unchanged — the renderer
1460
+ // list like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
1443
1461
  // recognizes the stride and speaks it as a cadence instead of the clock-time
1444
1462
  // cross-product.
1445
1463
  function hourStride(
1446
- ir: IR
1464
+ schedule: Schedule
1447
1465
  ): {start: number; interval: number; last: number} | null {
1448
- const segments = segmentsOf(ir, 'hour');
1466
+ const segments = segmentsOf(schedule, 'hour');
1449
1467
 
1450
1468
  if (segments.length === 1 && segments[0].kind === 'step') {
1451
1469
  const segment = segments[0];
@@ -1472,8 +1490,8 @@ function hourStride(
1472
1490
  // The second's status against a pinned minute: a wildcard or sub-minute step
1473
1491
  // fills the minute (a "durante un minuto" frame at minute 0); a single 0 is
1474
1492
  // just the top of the minute (no clause); anything else needs its own clause.
1475
- function subMinuteSecond(ir: IR): boolean {
1476
- return ir.pattern.second === '*' || ir.shapes.second === 'step';
1493
+ function subMinuteSecond(schedule: Schedule): boolean {
1494
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1477
1495
  }
1478
1496
 
1479
1497
  // The lead clause for an hour-cadence rendering: the second and the pinned
@@ -1483,24 +1501,26 @@ function subMinuteSecond(ir: IR): boolean {
1483
1501
  // minuto" frame (the whole minute-0 window). A non-zero minute is a real clock
1484
1502
  // minute: the second leads with its own clause (if any), then the minute reads
1485
1503
  // "en el minuto M".
1486
- function hourCadenceLead(ir: IR, minute: number, opts: Opts): string {
1504
+ function hourCadenceLead(
1505
+ schedule: Schedule, minute: number, opts: Opts
1506
+ ): string {
1487
1507
  if (minute === 0) {
1488
- if (subMinuteSecond(ir)) {
1489
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto';
1508
+ if (subMinuteSecond(schedule)) {
1509
+ return secondsClause(schedule, 'minuto', opts) + ' durante un minuto';
1490
1510
  }
1491
1511
 
1492
- return secondsClause(ir, 'hora', opts);
1512
+ return secondsClause(schedule, 'hora', opts);
1493
1513
  }
1494
1514
 
1495
1515
  const minutePhrase = 'en el minuto ' + minute;
1496
1516
 
1497
1517
  // A single 0 second is just the top of the minute, so the minute leads
1498
1518
  // alone; any other second prefixes its own clause.
1499
- if (ir.pattern.second === '0') {
1519
+ if (schedule.pattern.second === '0') {
1500
1520
  return minutePhrase;
1501
1521
  }
1502
1522
 
1503
- return secondsClause(ir, 'minuto', opts) + ', ' + minutePhrase;
1523
+ return secondsClause(schedule, 'minuto', opts) + ', ' + minutePhrase;
1504
1524
  }
1505
1525
 
1506
1526
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1511,9 +1531,11 @@ function hourCadenceLead(ir: IR, minute: number, opts: Opts): string {
1511
1531
  // enumeration is no longer than the cadence: a meaningful second makes every
1512
1532
  // clock time three digit-groups, so any stride is worth compacting; otherwise
1513
1533
  // the stride must exceed the clock-time cap, the same point at which the core
1514
- // itself stops enumerating. Renderer-only; the IR is unchanged.
1515
- function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1516
- const stride = hourStride(ir);
1534
+ // itself stops enumerating. Renderer-only; the Schedule is unchanged.
1535
+ function hourCadence(
1536
+ schedule: Schedule, minute: number, opts: Opts
1537
+ ): string | null {
1538
+ const stride = hourStride(schedule);
1517
1539
 
1518
1540
  if (!stride) {
1519
1541
  return null;
@@ -1526,7 +1548,7 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1526
1548
  // or "a partir de" form is no shorter than the list. A bounded or uneven
1527
1549
  // stride has no clean wrap, so its endpoint-pinning cadence ("cada cinco
1528
1550
  // horas de las 00:00 a las 20:00") reads better however short.
1529
- if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1551
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1530
1552
  offsetCleanStride(stride)) {
1531
1553
  return null;
1532
1554
  }
@@ -1535,31 +1557,31 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1535
1557
  // stride is a confinement, not a juxtaposed cadence: it reads "durante un
1536
1558
  // minuto, durante las horas pares", reusing the hour-step confinement idiom
1537
1559
  // so the minute-0 window is never heard as the bare hour cadence.
1538
- const confinement = minute === 0 && subMinuteSecond(ir) &&
1539
- cleanStrideSegment(ir);
1560
+ const confinement = minute === 0 && subMinuteSecond(schedule) &&
1561
+ cleanStrideSegment(schedule);
1540
1562
 
1541
1563
  if (confinement) {
1542
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1543
- stepHourSpan(confinement, opts) + trailingQualifier(ir, opts);
1564
+ return secondsClause(schedule, 'minuto', opts) + ' durante un minuto, ' +
1565
+ stepHourSpan(confinement, opts) + trailingQualifier(schedule, opts);
1544
1566
  }
1545
1567
 
1546
1568
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1547
1569
  // lead clause to fold in, so the bounded cadence stands on its own ("cada
1548
1570
  // cinco horas de las 00:00 a las 20:00").
1549
- if (minute === 0 && ir.pattern.second === '0') {
1550
- return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1571
+ if (minute === 0 && schedule.pattern.second === '0') {
1572
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1551
1573
  }
1552
1574
 
1553
- return hourCadenceLead(ir, minute, opts) + ', ' +
1554
- hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1575
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1576
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1555
1577
  }
1556
1578
 
1557
1579
  // The hour step segment when the hour is a clean stride es renders as a
1558
1580
  // confinement phrase ("durante las horas pares"); null otherwise (an offset or
1559
1581
  // bounded step, an uneven stride, or an arithmetic-progression list, which
1560
1582
  // keep the bounded cadence form).
1561
- function cleanStrideSegment(ir: IR): StepSegment | null {
1562
- const segments = segmentsOf(ir, 'hour');
1583
+ function cleanStrideSegment(schedule: Schedule): StepSegment | null {
1584
+ const segments = segmentsOf(schedule, 'hour');
1563
1585
  const segment = segments.length === 1 && segments[0];
1564
1586
 
1565
1587
  if (!segment || segment.kind !== 'step' ||
@@ -1574,8 +1596,8 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1574
1596
  // range — and so forms a window rather than a cross-product of clock times.
1575
1597
  // A pure single-value list (9,17) has no range to span and still enumerates;
1576
1598
  // a step is handled by hourStride/hourCadence.
1577
- function hasHourWindow(ir: IR): boolean {
1578
- return segmentsOf(ir, 'hour').some(function range(segment) {
1599
+ function hasHourWindow(schedule: Schedule): boolean {
1600
+ return segmentsOf(schedule, 'hour').some(function range(segment) {
1579
1601
  return segment.kind === 'range';
1580
1602
  });
1581
1603
  }
@@ -1587,9 +1609,12 @@ function hasHourWindow(ir: IR): boolean {
1587
1609
  // times. The hour-RANGE analog of hourCadence. Returns null when the hour has
1588
1610
  // no range, when the minute is non-zero (a real clock minute the existing
1589
1611
  // window form already speaks), or when a plain :00 set carries no clause.
1590
- // Renderer-only; the IR is unchanged.
1591
- function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1592
- if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1612
+ // Renderer-only; the Schedule is unchanged.
1613
+ function hourRangeCadence(
1614
+ schedule: Schedule, minute: number, opts: Opts
1615
+ ): string | null {
1616
+ if (minute !== 0 || !hasHourWindow(schedule) ||
1617
+ schedule.pattern.second === '0') {
1593
1618
  return null;
1594
1619
  }
1595
1620
 
@@ -1599,14 +1624,15 @@ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1599
1624
  // ("cada hora de las 09:00 a las 17:00") so the confinement is never heard
1600
1625
  // as it — the hour-range analog of "durante un minuto, durante las horas
1601
1626
  // pares".
1602
- if (subMinuteSecond(ir)) {
1603
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1604
- 'durante las horas ' + hourSegmentTimes(ir, 0, null, opts) +
1605
- trailingQualifier(ir, opts);
1627
+ if (subMinuteSecond(schedule)) {
1628
+ return secondsClause(schedule, 'minuto', opts) + ' durante un minuto, ' +
1629
+ 'durante las horas ' + hourSegmentTimes(schedule, 0, null, opts) +
1630
+ trailingQualifier(schedule, opts);
1606
1631
  }
1607
1632
 
1608
- return hourCadenceLead(ir, minute, opts) + ', ' +
1609
- hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1633
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1634
+ hourSegmentTimes(schedule, 0, null, opts) +
1635
+ trailingQualifier(schedule, opts);
1610
1636
  }
1611
1637
 
1612
1638
  // --- Hour-time phrasing. ---
@@ -1617,8 +1643,8 @@ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1617
1643
  // hour "de la hora de las HH:00" (the idiom a wildcard minute already uses).
1618
1644
  // Used by the compact-clock non-fold path, where the minute is a step or list
1619
1645
  // (a single-value minute keeps its real "a las HH:MM" clock time elsewhere).
1620
- function hourContextTimes(ir: IR, opts: Opts): string {
1621
- const segments = segmentsOf(ir, 'hour');
1646
+ function hourContextTimes(schedule: Schedule, opts: Opts): string {
1647
+ const segments = segmentsOf(schedule, 'hour');
1622
1648
 
1623
1649
  // Collect the point hours (singles and step fires) — a range stays a window.
1624
1650
  const points: number[] = [];
@@ -1686,7 +1712,7 @@ function atTimes(hours: number[], opts: Opts): string[] {
1686
1712
  // The hour times accompanying a lead clause: "a las 9:00 y a las 17:00",
1687
1713
  // with long expansions rendered segment by segment.
1688
1714
  function atHourTimes(
1689
- ir: IR,
1715
+ schedule: Schedule,
1690
1716
  times: HourTimesPlan,
1691
1717
  opts: Opts
1692
1718
  ): string {
@@ -1694,25 +1720,27 @@ function atHourTimes(
1694
1720
  return groupClockTimesByArticle(atTimes(times.fires, opts));
1695
1721
  }
1696
1722
 
1697
- return hourSegmentTimes(ir, 0, null, opts);
1723
+ return hourSegmentTimes(schedule, 0, null, opts);
1698
1724
  }
1699
1725
 
1700
1726
  // The active hours of a confined cadence: a few hours read as windows; many
1701
1727
  // read better as a compact list ("durante las horas de las 9, 11, 13, 15 y
1702
1728
  // 17") than as a sprawl of windows.
1703
- function hourSpanFromTimes(ir: IR, times: HourTimesPlan, opts: Opts): string {
1729
+ function hourSpanFromTimes(
1730
+ schedule: Schedule, times: HourTimesPlan, opts: Opts
1731
+ ): string {
1704
1732
  if (times.kind === 'fires' && times.fires.length > 3) {
1705
1733
  return 'durante las horas ' + hourSpanList(times.fires, opts);
1706
1734
  }
1707
1735
 
1708
- return hourWindowsFromTimes(ir, times, opts);
1736
+ return hourWindowsFromTimes(schedule, times, opts);
1709
1737
  }
1710
1738
 
1711
1739
  // Each fire hour as its own one-hour window: "de las 9:00 a las 9:59 y de
1712
1740
  // las 17:00 a las 17:59". Spanish prefers this to the English "during the
1713
1741
  // 9 a.m. and 5 p.m. hours" shape.
1714
1742
  function hourWindowsFromTimes(
1715
- ir: IR,
1743
+ schedule: Schedule,
1716
1744
  times: HourTimesPlan,
1717
1745
  opts: Opts
1718
1746
  ): string {
@@ -1722,7 +1750,7 @@ function hourWindowsFromTimes(
1722
1750
  }));
1723
1751
  }
1724
1752
 
1725
- return joinList(segmentsOf(ir, 'hour').map(function window(segment) {
1753
+ return joinList(segmentsOf(schedule, 'hour').map(function window(segment) {
1726
1754
  if (segment.kind === 'range') {
1727
1755
  return timeRange({hour: +segment.bounds[0], minute: 0},
1728
1756
  {hour: +segment.bounds[1], minute: 59}, opts);
@@ -1742,7 +1770,7 @@ function hourWindowsFromTimes(
1742
1770
  // (and optional second) folded into each: "de las 9:30 a las 20:30 y también
1743
1771
  // a las 22:30" when an isolated point-time follows a range.
1744
1772
  function hourSegmentTimes(
1745
- ir: IR,
1773
+ schedule: Schedule,
1746
1774
  minute: number,
1747
1775
  second: number | null | undefined,
1748
1776
  opts: Opts
@@ -1751,7 +1779,7 @@ function hourSegmentTimes(
1751
1779
  const pieces: string[] = [];
1752
1780
  const fromRange: boolean[] = [];
1753
1781
 
1754
- segmentsOf(ir, 'hour').forEach(function clock(segment) {
1782
+ segmentsOf(schedule, 'hour').forEach(function clock(segment) {
1755
1783
  if (segment.kind === 'step') {
1756
1784
  segment.fires.forEach(function each(hour) {
1757
1785
  pieces.push(atTime(timePhrase(hour, minute, second, opts)));
@@ -1942,23 +1970,23 @@ function dayPeriod(hour: number, opts: Opts): string {
1942
1970
  // lunes ", "el 13 de cada mes ", "de lunes a viernes ".
1943
1971
  // Date-OR-weekday unions skip this entirely — the unified frame in `render`
1944
1972
  // handles the month lead and day-level suffix.
1945
- function leadingQualifier(ir: IR, opts: Opts): string {
1946
- const pattern = ir.pattern;
1973
+ function leadingQualifier(schedule: Schedule, opts: Opts): string {
1974
+ const pattern = schedule.pattern;
1947
1975
 
1948
1976
  if (pattern.date !== '*' && pattern.weekday !== '*') {
1949
1977
  return '';
1950
1978
  }
1951
1979
 
1952
1980
  if (pattern.date !== '*') {
1953
- return datePhrase(ir, opts) + ' ';
1981
+ return datePhrase(schedule, opts) + ' ';
1954
1982
  }
1955
1983
 
1956
1984
  if (pattern.weekday !== '*') {
1957
- return weekdayQualifier(ir) + monthScope(ir) + ' ';
1985
+ return weekdayQualifier(schedule) + monthScope(schedule) + ' ';
1958
1986
  }
1959
1987
 
1960
1988
  if (pattern.month !== '*') {
1961
- return 'todos los días ' + monthPhrase(ir, 'de ') + ' ';
1989
+ return 'todos los días ' + monthPhrase(schedule, 'de ') + ' ';
1962
1990
  }
1963
1991
 
1964
1992
  return 'todos los días ';
@@ -1968,23 +1996,23 @@ function leadingQualifier(ir: IR, opts: Opts): string {
1968
1996
  // de cada mes". Empty when no day-level field is set.
1969
1997
  // Date-OR-weekday unions skip this entirely — the unified frame in `render`
1970
1998
  // handles the month lead and day-level suffix.
1971
- function trailingQualifier(ir: IR, opts: Opts): string {
1972
- const pattern = ir.pattern;
1999
+ function trailingQualifier(schedule: Schedule, opts: Opts): string {
2000
+ const pattern = schedule.pattern;
1973
2001
 
1974
2002
  if (pattern.date !== '*' && pattern.weekday !== '*') {
1975
2003
  return '';
1976
2004
  }
1977
2005
 
1978
2006
  if (pattern.date !== '*') {
1979
- return ' ' + datePhrase(ir, opts);
2007
+ return ' ' + datePhrase(schedule, opts);
1980
2008
  }
1981
2009
 
1982
2010
  if (pattern.weekday !== '*') {
1983
- return ' ' + weekdayQualifier(ir) + monthScope(ir);
2011
+ return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
1984
2012
  }
1985
2013
 
1986
2014
  if (pattern.month !== '*') {
1987
- return ' ' + monthPhrase(ir, 'en ');
2015
+ return ' ' + monthPhrase(schedule, 'en ');
1988
2016
  }
1989
2017
 
1990
2018
  return '';
@@ -1993,24 +2021,24 @@ function trailingQualifier(ir: IR, opts: Opts): string {
1993
2021
  // The date qualifier: "el 13 de junio", "los días 1 y 15 de cada mes",
1994
2022
  // "del 1 al 15 de cada mes", or a Quartz phrase. A foldable single year
1995
2023
  // joins the date ("el 25 de diciembre de 2030").
1996
- function datePhrase(ir: IR, opts: Opts): string {
1997
- const pattern = ir.pattern;
2024
+ function datePhrase(schedule: Schedule, opts: Opts): string {
2025
+ const pattern = schedule.pattern;
1998
2026
 
1999
2027
  if (quartzDatePhrase(pattern.date) || isOpenStep(pattern.date)) {
2000
- return dateClause(ir, '', opts) + monthScope(ir);
2028
+ return dateClause(schedule, '', opts) + monthScope(schedule);
2001
2029
  }
2002
2030
 
2003
- return dateClause(ir, dateMonthPart(ir), opts);
2031
+ return dateClause(schedule, dateMonthPart(schedule), opts);
2004
2032
  }
2005
2033
 
2006
2034
  // The date words with a caller-chosen month part. Quartz phrases and open
2007
2035
  // steps are self-contained and ignore the month part.
2008
2036
  function dateClause(
2009
- ir: IR,
2037
+ schedule: Schedule,
2010
2038
  monthPart: string,
2011
2039
  opts: Opts
2012
2040
  ): string {
2013
- const pattern = ir.pattern;
2041
+ const pattern = schedule.pattern;
2014
2042
  const quartz = quartzDatePhrase(pattern.date);
2015
2043
 
2016
2044
  if (quartz) {
@@ -2021,25 +2049,25 @@ function dateClause(
2021
2049
  return stepDates(pattern.date, opts);
2022
2050
  }
2023
2051
 
2024
- const segments = segmentsOf(ir, 'date');
2052
+ const segments = segmentsOf(schedule, 'date');
2025
2053
 
2026
2054
  if (segments.length === 1 && segments[0].kind === 'range') {
2027
2055
  return 'del ' + segments[0].bounds[0] + ' al ' +
2028
- segments[0].bounds[1] + monthPart + foldedYear(ir);
2056
+ segments[0].bounds[1] + monthPart + foldedYear(schedule);
2029
2057
  }
2030
2058
 
2031
2059
  if (segments.length === 1 && segments[0].kind === 'single') {
2032
- return 'el ' + segments[0].value + monthPart + foldedYear(ir);
2060
+ return 'el ' + segments[0].value + monthPart + foldedYear(schedule);
2033
2061
  }
2034
2062
 
2035
2063
  return 'los días ' + joinList(segmentWords(segments)) + monthPart +
2036
- foldedYear(ir);
2064
+ foldedYear(schedule);
2037
2065
  }
2038
2066
 
2039
2067
  // Whether the month field contains a range segment.
2040
- function monthRanged(ir: IR): boolean {
2041
- return ir.pattern.month !== '*' &&
2042
- segmentsOf(ir, 'month').some(function range(segment) {
2068
+ function monthRanged(schedule: Schedule): boolean {
2069
+ return schedule.pattern.month !== '*' &&
2070
+ segmentsOf(schedule, 'month').some(function range(segment) {
2043
2071
  return segment.kind === 'range';
2044
2072
  });
2045
2073
  }
@@ -2049,21 +2077,21 @@ function monthRanged(ir: IR): boolean {
2049
2077
  // "el 1 de junio a septiembre" parses as "(el 1 de junio) a septiembre" —
2050
2078
  // so it scopes the date instead ("el 1 de cada mes, de junio a
2051
2079
  // septiembre").
2052
- function dateMonthPart(ir: IR): string {
2053
- if (ir.pattern.month === '*') {
2080
+ function dateMonthPart(schedule: Schedule): string {
2081
+ if (schedule.pattern.month === '*') {
2054
2082
  return ' de cada mes';
2055
2083
  }
2056
2084
 
2057
- if (monthRanged(ir)) {
2058
- return ' de cada mes, ' + monthPhrase(ir, 'de ');
2085
+ if (monthRanged(schedule)) {
2086
+ return ' de cada mes, ' + monthPhrase(schedule, 'de ');
2059
2087
  }
2060
2088
 
2061
- return ' ' + monthPhrase(ir, 'de ');
2089
+ return ' ' + monthPhrase(schedule, 'de ');
2062
2090
  }
2063
2091
 
2064
2092
  // "de 2030" when a single year can fold into a calendar date.
2065
- function foldedYear(ir: IR): string {
2066
- const yearField = ir.pattern.year;
2093
+ function foldedYear(schedule: Schedule): string {
2094
+ const yearField = schedule.pattern.year;
2067
2095
 
2068
2096
  if (yearField === '*' || yearField.indexOf('/') !== -1 ||
2069
2097
  yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
@@ -2119,16 +2147,16 @@ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
2119
2147
  // miércoles y viernes". No "todos" prefix: the plural definite article
2120
2148
  // ("los lunes") already conveys "every Monday" in Spanish, unlike "todos
2121
2149
  // los días", where "los días" alone does not mean "every day".
2122
- function weekdayQualifier(ir: IR): string {
2123
- const quartz = quartzWeekdayPhrase(ir.pattern.weekday);
2150
+ function weekdayQualifier(schedule: Schedule): string {
2151
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
2124
2152
 
2125
2153
  if (quartz) {
2126
2154
  return quartz;
2127
2155
  }
2128
2156
 
2129
2157
  // Weekday lists display Monday-first (Sunday last); a lone range keeps its
2130
- // form. The IR stays canonical (Sunday=0). The helper flattens steps.
2131
- const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
2158
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
2159
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
2132
2160
  const allSingles = segments.every(function single(segment) {
2133
2161
  return segment.kind === 'single';
2134
2162
  });
@@ -2180,8 +2208,8 @@ function flattenSteps(segments: Segment[]): NameSegment[] {
2180
2208
  // as one unit, so in mixed lists every piece repeats its preposition
2181
2209
  // ("en enero y de marzo a junio") — a bare "enero y marzo a junio" parses
2182
2210
  // as "(enero y marzo) a junio".
2183
- function monthPhrase(ir: IR, lead: string): string {
2184
- const segments = flattenSteps(segmentsOf(ir, 'month'));
2211
+ function monthPhrase(schedule: Schedule, lead: string): string {
2212
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
2185
2213
  const ranged = segments.some(function range(segment) {
2186
2214
  return segment.kind === 'range';
2187
2215
  });
@@ -2207,15 +2235,39 @@ function monthPhrase(ir: IR, lead: string): string {
2207
2235
  // junio"). A ranged scope sets off with a comma ("el último día del mes,
2208
2236
  // de junio a septiembre") — gluing "de junio" after "del mes"
2209
2237
  // garden-paths.
2210
- function monthScope(ir: IR): string {
2211
- if (ir.pattern.month === '*') {
2238
+ function monthScope(schedule: Schedule): string {
2239
+ if (schedule.pattern.month === '*') {
2212
2240
  return '';
2213
2241
  }
2214
2242
 
2215
- return (monthRanged(ir) ? ', ' : ' ') + monthPhrase(ir, 'de ');
2243
+ return (monthRanged(schedule) ? ', ' : ' ') + monthPhrase(schedule, 'de ');
2216
2244
  }
2217
2245
 
2218
2246
  // Open day-of-month steps: "cada 2 días del mes (desde el 5)".
2247
+ // The parity predicate for a `*/2`-style day-of-month step, used only inside
2248
+ // the OR union frame (see domArm). `*/2` and `1/2` fire on the odd days
2249
+ // (1, 3, …, 31); `2/2` fires on the even days. Any other open step has no
2250
+ // parity reading, so the caller falls back to stepDates.
2251
+ function parityDayPredicate(dateField: string): string | undefined {
2252
+ if (!isOpenStep(dateField)) {
2253
+ return;
2254
+ }
2255
+
2256
+ const [start, step] = dateField.split('/');
2257
+
2258
+ if (+step !== 2) {
2259
+ return;
2260
+ }
2261
+
2262
+ if (start === '*' || start === '1') {
2263
+ return 'un día impar del mes';
2264
+ }
2265
+
2266
+ if (start === '2') {
2267
+ return 'un día par del mes';
2268
+ }
2269
+ }
2270
+
2219
2271
  function stepDates(dateField: string, opts: Opts): string {
2220
2272
  const parts = dateField.split('/');
2221
2273
  let phrase = 'cada ' + numero(+parts[1], opts) + ' días del mes';
@@ -2233,10 +2285,10 @@ function stepDates(dateField: string, opts: Opts): string {
2233
2285
  // "en 2030, 2031 y 2032", "cada dos años desde 2030".
2234
2286
  function applyYear(
2235
2287
  description: string,
2236
- ir: IR,
2288
+ schedule: Schedule,
2237
2289
  opts: Opts
2238
2290
  ): string {
2239
- const yearField = ir.pattern.year;
2291
+ const yearField = schedule.pattern.year;
2240
2292
 
2241
2293
  if (yearField === '*') {
2242
2294
  return description;
@@ -2247,7 +2299,7 @@ function applyYear(
2247
2299
  }
2248
2300
 
2249
2301
  // A foldable single year already joined its date in datePhrase.
2250
- if (foldedYear(ir) && ir.pattern.date !== '*') {
2302
+ if (foldedYear(schedule) && schedule.pattern.date !== '*') {
2251
2303
  return description;
2252
2304
  }
2253
2305
 
@@ -2345,7 +2397,7 @@ function monthName(token: NameToken): string {
2345
2397
  }
2346
2398
 
2347
2399
 
2348
- // The Spanish language module: the IR renderer plus the language-owned
2400
+ // The Spanish language module: the Schedule renderer plus the language-owned
2349
2401
  // strings and option normalization.
2350
2402
  const es: Language<SpanishStyle> = {
2351
2403
  describe,