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,30 +1,34 @@
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
7
- // words, and is free to re-strategize where Spanish grammar prefers a
6
+ // (docs/i18n-design.md §7): it consumes only the Schedule, owns all of its
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).
10
10
 
11
11
  import {clockDigits, numeral} from '../../core/format.js';
12
12
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
13
+ import {isOpenStep} from '../../core/shapes.js';
13
14
  import {
14
- arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
15
- } 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';
16
20
  import type {Cronli5Options} from '../../types.js';
17
21
  import type {
18
- Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
22
+ HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
19
23
  Segment
20
- } from '../../core/ir.js';
24
+ } from '../../core/schedule.js';
21
25
  import {resolveDialect, type SpanishStyle} from './dialects.js';
22
26
 
23
27
  // Normalized options carrying Spanish's own style shape.
24
28
  type Opts = NormalizedOptions<SpanishStyle>;
25
29
 
26
30
  // The erased renderer signature the dispatch table maps to.
27
- type Renderer = (ir: IR, plan: PlanNode, opts: Opts) => string;
31
+ type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
28
32
 
29
33
  // A `step` segment, narrowed from the discriminated `Segment` union.
30
34
  type StepSegment = Extract<Segment, {kind: 'step'}>;
@@ -62,26 +66,6 @@ type NameSegment =
62
66
  type RangeNameSegment = Extract<NameSegment, {kind: 'range'}>;
63
67
  type SingleNameSegment = Extract<NameSegment, {kind: 'single'}>;
64
68
 
65
- // The first (and only) segment of a step field. The plan only routes here
66
- // for step shapes, whose segments list is present and step-kinded; this
67
- // asserts what the analysis guarantees but the type cannot express.
68
- function stepSegment(segments: Segment[] | null): StepSegment {
69
- return (segments as Segment[])[0] as StepSegment;
70
- }
71
-
72
- // The hour field's classified segments. Callers reach here only for hour
73
- // shapes the analysis segmented, so the list is present; the type permits
74
- // null (wildcard/quartz) that these paths never carry.
75
- function hourSegments(ir: IR): Segment[] {
76
- return ir.analyses.segments.hour as Segment[];
77
- }
78
-
79
- // A field's classified segments. Callers reach a segment list only when the
80
- // field is non-wildcard and non-quartz, where the analysis always produced
81
- // one; the type's null (those two shapes) is unreachable on these paths.
82
- function fieldSegments(ir: IR, field: Field): Segment[] {
83
- return ir.analyses.segments[field] as Segment[];
84
- }
85
69
 
86
70
  // Spanish number names for the integers zero through ten.
87
71
  const numeros = [
@@ -148,22 +132,22 @@ function normalizeOptions(options?: Cronli5Options): Opts {
148
132
  };
149
133
  }
150
134
 
151
- // Render an analyzed cron pattern (the IR) as Spanish.
152
- function describe(ir: IR, opts: Opts): string {
153
- 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);
154
138
  }
155
139
 
156
140
  // Render one plan node. `composeSeconds` recurses with its `rest` plan.
157
141
  // When BOTH date and weekday are restricted (a date-OR-weekday union), the
158
142
  // result is wrapped in the unified `[month] [time], ya sea <DOM> o <DOW>`
159
143
  // frame regardless of arm shapes or month type.
160
- function render(ir: IR, plan: PlanNode, opts: Opts): string {
144
+ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
161
145
  // Each renderer narrows `plan` to its own `kind`; the dispatch table is
162
146
  // keyed by that discriminant, so the union-to-specific match is sound but
163
147
  // not expressible without a cast.
164
- const phrase = (renderers[plan.kind] as Renderer)(ir, plan, opts);
148
+ const phrase = (renderers[plan.kind] as Renderer)(schedule, plan, opts);
165
149
 
166
- if (!isDateWeekdayUnion(ir)) {
150
+ if (!isDateWeekdayUnion(schedule)) {
167
151
  return phrase;
168
152
  }
169
153
 
@@ -171,55 +155,56 @@ function render(ir: IR, plan: PlanNode, opts: Opts): string {
171
155
  // (leadingQualifier and trailingQualifier both return '' for union patterns).
172
156
  // Front the shared month (possibly with a trailing comma for enumerations),
173
157
  // then append the union correlative last.
174
- const lead = unionMonthLeadFull(ir);
158
+ const lead = unionMonthLeadFull(schedule);
175
159
 
176
- return (lead ? lead + ' ' : '') + phrase + unionYaseaSuffix(ir, opts);
160
+ return (lead ? lead + ' ' : '') + phrase + unionYaseaSuffix(schedule, opts);
177
161
  }
178
162
 
179
163
  // --- Seconds renderers. ---
180
164
 
181
165
  function renderEverySecond(
182
- ir: IR,
166
+ schedule: Schedule,
183
167
  plan: Extract<PlanNode, {kind: 'everySecond'}>,
184
168
  opts: Opts
185
169
  ): string {
186
- return 'cada segundo' + trailingQualifier(ir, opts);
170
+ return 'cada segundo' + trailingQualifier(schedule, opts);
187
171
  }
188
172
 
189
173
  function renderStandaloneSeconds(
190
- ir: IR,
174
+ schedule: Schedule,
191
175
  plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
192
176
  opts: Opts
193
177
  ): string {
194
- return secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
178
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
195
179
  }
196
180
 
197
181
  function renderSecondPastMinute(
198
- ir: IR,
182
+ schedule: Schedule,
199
183
  plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
200
184
  opts: Opts
201
185
  ): string {
202
- return 'en el segundo ' + ir.pattern.second + ' de cada minuto' +
203
- trailingQualifier(ir, opts);
186
+ return 'en el segundo ' + schedule.pattern.second + ' de cada minuto' +
187
+ trailingQualifier(schedule, opts);
204
188
  }
205
189
 
206
190
  // A meaningful second combined with a single specific minute (and an open
207
191
  // hour): a single second folds into the minute anchor; a list, range, or
208
192
  // step leads with its own clause.
209
193
  function renderSecondsWithinMinute(
210
- ir: IR,
194
+ schedule: Schedule,
211
195
  plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
212
196
  opts: Opts
213
197
  ): string {
214
- const minuteField = ir.pattern.minute;
198
+ const minuteField = schedule.pattern.minute;
215
199
 
216
200
  if (plan.singleSecond) {
217
201
  return 'en el minuto ' + minuteField + ' y el segundo ' +
218
- ir.pattern.second + ' de cada hora' + trailingQualifier(ir, opts);
202
+ schedule.pattern.second + ' de cada hora' +
203
+ trailingQualifier(schedule, opts);
219
204
  }
220
205
 
221
- return secondsLeadClause(ir, opts) + ', en el minuto ' + minuteField +
222
- ' de cada hora' + trailingQualifier(ir, opts);
206
+ return secondsLeadClause(schedule, opts) + ', en el minuto ' + minuteField +
207
+ ' de cada hora' + trailingQualifier(schedule, opts);
223
208
  }
224
209
 
225
210
  // A seconds list nested into one or more fixed clock times ("..., en los
@@ -228,7 +213,7 @@ function renderSecondsWithinMinute(
228
213
  // are listed. The clock time follows with the genitive "de", so the stride
229
214
  // drops its "de cada minuto" anchor.
230
215
  function secondsListAtClock(
231
- ir: IR,
216
+ schedule: Schedule,
232
217
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
233
218
  opts: Opts
234
219
  ): string {
@@ -240,10 +225,10 @@ function secondsListAtClock(
240
225
  // prepend "de " to produce the genitive form "de las 09:00 y 17:00".
241
226
  const clockList = grouped.startsWith('a ') ? grouped.slice(2) : grouped;
242
227
  const stride =
243
- strideFromSegments(fieldSegments(ir, 'second'), 'segundo', '', opts);
228
+ strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', '', opts);
244
229
  const secondsPhrase = stride ?? 'en los segundos ' +
245
- joinList(segmentWords(fieldSegments(ir, 'second')));
246
- const dayFrame = trailingQualifier(ir, opts);
230
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
231
+ const dayFrame = trailingQualifier(schedule, opts);
247
232
 
248
233
  return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
249
234
  secondsPhrase + ' de ' + clockList;
@@ -254,35 +239,37 @@ function secondsListAtClock(
254
239
  // when that does not apply (a non-clock rest, a multi-valued minute, or an
255
240
  // hour that is not a stride).
256
241
  function composeHourCadence(
257
- ir: IR,
242
+ schedule: Schedule,
258
243
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
259
244
  opts: Opts
260
245
  ): string | null {
261
246
  const clockRest = plan.rest.kind === 'clockTimes' ||
262
247
  plan.rest.kind === 'compactClockTimes';
263
248
 
264
- if (!clockRest || ir.shapes.minute !== 'single') {
249
+ if (!clockRest || schedule.shapes.minute !== 'single') {
265
250
  return null;
266
251
  }
267
252
 
268
- const minute = +ir.pattern.minute;
253
+ const minute = +schedule.pattern.minute;
269
254
 
270
- return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
255
+ return hourCadence(schedule, minute, opts) ??
256
+ hourRangeCadence(schedule, minute, opts);
271
257
  }
272
258
 
273
259
  // A wildcard or stepped second with a fixed minute across one or more specific
274
260
  // hours: the seconds confine to the clock time(s), each minute named.
275
261
  function isPinnedMinuteSeconds(
276
- ir: IR,
262
+ schedule: Schedule,
277
263
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
278
264
  ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
279
265
  {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
280
266
  return plan.rest.kind === 'clockTimes' &&
281
- (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step');
267
+ (schedule.shapes.second === 'wildcard' ||
268
+ schedule.shapes.second === 'step');
282
269
  }
283
270
 
284
271
  function renderComposeSeconds(
285
- ir: IR,
272
+ schedule: Schedule,
286
273
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
287
274
  opts: Opts
288
275
  ): string {
@@ -290,7 +277,7 @@ function renderComposeSeconds(
290
277
  // minute is a cadence, not a wall of clock times: the second/minute lead,
291
278
  // then the hour cadence ("en el segundo 30 de cada hora, cada dos horas").
292
279
  // The clock-time rest would otherwise cross-multiply the hours.
293
- const hourCad = composeHourCadence(ir, plan, opts);
280
+ const hourCad = composeHourCadence(schedule, plan, opts);
294
281
 
295
282
  if (hourCad !== null) {
296
283
  return hourCad;
@@ -298,28 +285,28 @@ function renderComposeSeconds(
298
285
 
299
286
  // A wildcard or stepped second with the minute pinned to a single value
300
287
  // across one or more specific hours: the seconds confine to the clock time.
301
- if (isPinnedMinuteSeconds(ir, plan)) {
302
- return pinnedMinuteSeconds(ir, plan.rest, opts);
288
+ if (isPinnedMinuteSeconds(schedule, plan)) {
289
+ return pinnedMinuteSeconds(schedule, plan.rest, opts);
303
290
  }
304
291
 
305
292
  // Seconds list + fixed clock time: nest the seconds into the clock time(s)
306
293
  // with genitive "de las HH:MM" instead of "de cada minuto"; the minute is
307
294
  // fixed so "de cada minuto" is misleading. Single seconds already fold into
308
295
  // the time in the clockTimes renderer; step seconds keep their own clause.
309
- if (plan.rest.kind === 'clockTimes' && ir.shapes.second === 'list') {
310
- return secondsListAtClock(ir, plan.rest, opts);
296
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.second === 'list') {
297
+ return secondsListAtClock(schedule, plan.rest, opts);
311
298
  }
312
299
 
313
300
  // Second-step + fixed minute + hour range + weekday: anchor the cadence to
314
301
  // the minute after the weekday + hour-range frame.
315
- if (plan.rest.kind === 'hourRange' && ir.shapes.second === 'step' &&
316
- ir.pattern.weekday !== '*') {
302
+ if (plan.rest.kind === 'hourRange' && schedule.shapes.second === 'step' &&
303
+ schedule.pattern.weekday !== '*') {
317
304
  const restNode = plan.rest;
318
305
  const window = hourWindow(boundedWindow(restNode), opts);
319
- const dayFrame = weekdayQualifier(ir) + monthScope(ir);
306
+ const dayFrame = weekdayQualifier(schedule) + monthScope(schedule);
320
307
  const cadence = 'cada ' +
321
- numero(stepSegment(ir.analyses.segments.second).interval, opts) +
322
- ' segundos del minuto ' + ir.pattern.minute;
308
+ numero(stepSegment(schedule, 'second').interval, opts) +
309
+ ' segundos del minuto ' + schedule.pattern.minute;
323
310
 
324
311
  return dayFrame + ', ' + window + ', ' + cadence;
325
312
  }
@@ -329,8 +316,9 @@ function renderComposeSeconds(
329
316
  // Bind them with the genitive "de" ("cada segundo de cada dos minutos"),
330
317
  // mirroring English. Other strides, a restricted hour, and an hour cadence
331
318
  // keep the juxtaposed form.
332
- if (isEveryOtherMinuteSeconds(ir, plan)) {
333
- 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);
334
322
  }
335
323
 
336
324
  // A compact clock-time rest folds a meaningful SINGLE second into its own
@@ -338,24 +326,25 @@ function renderComposeSeconds(
338
326
  // double it. A wildcard or stepped second is not folded there (no
339
327
  // clockSecond), so it still leads its own clause here.
340
328
  const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
341
- ir.analyses.clockSecond;
342
- const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
329
+ schedule.analyses.clockSecond;
330
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
343
331
 
344
- return lead + render(ir, plan.rest, opts);
332
+ return lead + render(schedule, plan.rest, opts);
345
333
  }
346
334
 
347
335
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
348
336
  // cadences read as contradictory side by side, so they bind into one.
349
337
  function isEveryOtherMinuteSeconds(
350
- ir: IR,
338
+ schedule: Schedule,
351
339
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>
352
340
  ): boolean {
353
341
  if (plan.rest.kind !== 'minuteFrequency' ||
354
- ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
342
+ schedule.shapes.second !== 'wildcard' ||
343
+ schedule.shapes.hour !== 'wildcard') {
355
344
  return false;
356
345
  }
357
346
 
358
- const minuteStep = stepSegment(ir.analyses.segments.minute);
347
+ const minuteStep = stepSegment(schedule, 'minute');
359
348
 
360
349
  return minuteStep.startToken === '*' && minuteStep.interval === 2;
361
350
  }
@@ -371,11 +360,11 @@ function isEveryOtherMinuteSeconds(
371
360
  // minute is an unambiguous clock time, so the genitive "de las 09:05" form
372
361
  // reads it as the minute, never the hour.
373
362
  function pinnedMinuteSeconds(
374
- ir: IR,
363
+ schedule: Schedule,
375
364
  rest: Extract<PlanNode, {kind: 'clockTimes'}>,
376
365
  opts: Opts
377
366
  ): string {
378
- const dayTrail = leadingQualifier(ir, opts).trimEnd();
367
+ const dayTrail = leadingQualifier(schedule, opts).trimEnd();
379
368
  const trail = dayTrail ? ', ' + dayTrail : '';
380
369
 
381
370
  // The "durante un minuto a las 9" duration form drops the clock minute, so it
@@ -383,18 +372,18 @@ function pinnedMinuteSeconds(
383
372
  // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
384
373
  // minute, never collapse to the bare hour (which once repeated it, "a las 9 y
385
374
  // 9"), so it takes the explicit clock list.
386
- if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
387
- 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 ' +
388
377
  durationHourList(rest.times, opts) + trail;
389
378
  }
390
379
 
391
- return secondsLeadClause(ir, opts) + ' de ' +
380
+ return secondsLeadClause(schedule, opts) + ' de ' +
392
381
  explicitClockList(rest.times, opts) + trail;
393
382
  }
394
383
 
395
384
  // The leading clause describing a second field relative to the minute.
396
- function secondsLeadClause(ir: IR, opts: Opts): string {
397
- return secondsClause(ir, 'minuto', opts);
385
+ function secondsLeadClause(schedule: Schedule, opts: Opts): string {
386
+ return secondsClause(schedule, 'minuto', opts);
398
387
  }
399
388
 
400
389
  // The second clause counted against an arbitrary anchor. The anchor is
@@ -402,16 +391,16 @@ function secondsLeadClause(ir: IR, opts: Opts): string {
402
391
  // pinned minute 0 into the hour and counts the second "de cada hora" instead
403
392
  // ("en el segundo 30 de cada hora"), so the minute-0 confinement is stated,
404
393
  // not dropped.
405
- function secondsClause(ir: IR, anchor: string, opts: Opts): string {
406
- const secondField = ir.pattern.second;
407
- 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;
408
397
 
409
398
  if (secondField === '*') {
410
399
  return 'cada segundo';
411
400
  }
412
401
 
413
402
  if (shape === 'step') {
414
- return stepCycle60(stepSegment(ir.analyses.segments.second), 'segundo',
403
+ return stepCycle60(stepSegment(schedule, 'second'), 'segundo',
415
404
  anchor, opts);
416
405
  }
417
406
 
@@ -426,55 +415,55 @@ function secondsClause(ir: IR, anchor: string, opts: Opts): string {
426
415
  return 'en el segundo ' + secondField + ' de cada ' + anchor;
427
416
  }
428
417
 
429
- return strideFromSegments(fieldSegments(ir, 'second'), 'segundo', anchor,
418
+ return strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', anchor,
430
419
  opts) ?? 'en los segundos ' +
431
- joinList(segmentWords(fieldSegments(ir, 'second'))) +
420
+ joinList(segmentWords(segmentsOf(schedule, 'second'))) +
432
421
  ' de cada ' + anchor;
433
422
  }
434
423
 
435
424
  // --- Minute renderers. ---
436
425
 
437
426
  function renderEveryMinute(
438
- ir: IR,
427
+ schedule: Schedule,
439
428
  plan: Extract<PlanNode, {kind: 'everyMinute'}>,
440
429
  opts: Opts
441
430
  ): string {
442
- return 'cada minuto' + trailingQualifier(ir, opts);
431
+ return 'cada minuto' + trailingQualifier(schedule, opts);
443
432
  }
444
433
 
445
434
  function renderSingleMinute(
446
- ir: IR,
435
+ schedule: Schedule,
447
436
  plan: Extract<PlanNode, {kind: 'singleMinute'}>,
448
437
  opts: Opts
449
438
  ): string {
450
- return 'en el minuto ' + ir.pattern.minute + ' de cada hora' +
451
- trailingQualifier(ir, opts);
439
+ return 'en el minuto ' + schedule.pattern.minute + ' de cada hora' +
440
+ trailingQualifier(schedule, opts);
452
441
  }
453
442
 
454
443
  function renderRangeOfMinutes(
455
- ir: IR,
444
+ schedule: Schedule,
456
445
  plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
457
446
  opts: Opts
458
447
  ): string {
459
- return minuteRangeLead(ir.pattern.minute) + ' de cada hora' +
460
- trailingQualifier(ir, opts);
448
+ return minuteRangeLead(schedule.pattern.minute) + ' de cada hora' +
449
+ trailingQualifier(schedule, opts);
461
450
  }
462
451
 
463
452
  function renderMultipleMinutes(
464
- ir: IR,
453
+ schedule: Schedule,
465
454
  plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
466
455
  opts: Opts
467
456
  ): string {
468
- return minutesList(ir, opts) + trailingQualifier(ir, opts);
457
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
469
458
  }
470
459
 
471
460
  // "en los minutos 5, 10 y 30 de cada hora". An offset/uneven step the core
472
461
  // enumerated to this list reads as a stride cadence when the fires form a
473
462
  // long-enough progression.
474
- function minutesList(ir: IR, opts: Opts): string {
475
- return strideFromSegments(fieldSegments(ir, 'minute'), 'minuto', 'hora',
463
+ function minutesList(schedule: Schedule, opts: Opts): string {
464
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minuto', 'hora',
476
465
  opts) ?? 'en los minutos ' +
477
- joinList(segmentWords(fieldSegments(ir, 'minute'))) + ' de cada hora';
466
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
478
467
  }
479
468
 
480
469
  // "cada minuto del 0 al 30". The standalone renderer adds "de cada hora";
@@ -496,7 +485,7 @@ function singleHourStep(segments: Segment[] | null): boolean {
496
485
  // A single hour step as a confinement. A stride of two over the whole day
497
486
  // reads idiomatically as the even ("las horas pares") or odd ("impares")
498
487
  // hours; any other step names its active hours, which pins the schedule
499
- // precisely (a panel found ordinal/colloquial forms imprecise).
488
+ // precisely (ordinal/colloquial forms would be imprecise here).
500
489
  function stepHourSpan(segment: StepSegment, opts: Opts): string {
501
490
  const bounded = segment.startToken.indexOf('-') !== -1;
502
491
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
@@ -576,17 +565,17 @@ function spanHours(hours: number[]): string {
576
565
 
577
566
  // A repeating minute step, qualified by the active hour window(s).
578
567
  function renderMinuteFrequency(
579
- ir: IR,
568
+ schedule: Schedule,
580
569
  plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
581
570
  opts: Opts
582
571
  ): string {
583
- let phrase = stepCycle60(stepSegment(ir.analyses.segments.minute), 'minuto',
572
+ let phrase = stepCycle60(stepSegment(schedule, 'minute'), 'minuto',
584
573
  'hora', opts);
585
574
 
586
575
  if (plan.hours.kind === 'during') {
587
576
  // A uneven hour stride confines the minute cadence to its own bounded hour
588
577
  // cadence ("cada 15 minutos, cada cinco horas de las 00:00 a las 20:00").
589
- const cadence = unevenHourCadence(ir, opts);
578
+ const cadence = unevenHourCadence(schedule, opts);
590
579
 
591
580
  if (cadence) {
592
581
  phrase += ', ' + cadence;
@@ -594,9 +583,9 @@ function renderMinuteFrequency(
594
583
  else {
595
584
  // An offset step (e.g. 1/2) arrives here; a single step reads as a
596
585
  // confinement, not the verbose window list.
597
- phrase += singleHourStep(ir.analyses.segments.hour) ?
598
- ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) :
599
- ' ' + 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);
600
589
  }
601
590
  }
602
591
  else if (plan.hours.kind === 'window') {
@@ -605,10 +594,10 @@ function renderMinuteFrequency(
605
594
  else if (plan.hours.kind === 'step') {
606
595
  // A clean stride is a confinement ("las horas pares", or the active-hour
607
596
  // list), never a juxtaposed cadence ("cada dos horas").
608
- phrase += ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts);
597
+ phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
609
598
  }
610
599
 
611
- return phrase + trailingQualifier(ir, opts);
600
+ return phrase + trailingQualifier(schedule, opts);
612
601
  }
613
602
 
614
603
  // "cada minuto de las 9:00 a las 9:29 de la mañana". A wildcard minute is the
@@ -616,144 +605,144 @@ function renderMinuteFrequency(
616
605
  // 09:00") rather than a synthesized "de las HH:00 a las HH:59" range the
617
606
  // source never stated; a plain range is a real window and keeps "de … a …".
618
607
  function renderMinuteSpanInHour(
619
- ir: IR,
608
+ schedule: Schedule,
620
609
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
621
610
  opts: Opts
622
611
  ): string {
623
- if (ir.pattern.minute === '*') {
612
+ if (schedule.pattern.minute === '*') {
624
613
  return 'cada minuto de la hora ' +
625
614
  fromTime(timePhrase(plan.hour, 0, null, opts)) +
626
- trailingQualifier(ir, opts);
615
+ trailingQualifier(schedule, opts);
627
616
  }
628
617
 
629
618
  return 'cada minuto ' +
630
619
  timeRange({hour: plan.hour, minute: plan.span[0]},
631
620
  {hour: plan.hour, minute: plan.span[1]}, opts) +
632
- trailingQualifier(ir, opts);
621
+ trailingQualifier(schedule, opts);
633
622
  }
634
623
 
635
- // A minute window under discrete hours. Spanish re-strategizes the
624
+ // A minute window under discrete hours. Spanish re-plans the
636
625
  // wildcard form: rather than "during the X hours", each hour reads as its
637
626
  // own window ("de las 9:00 a las 9:59").
638
627
  function renderMinutesAcrossHours(
639
- ir: IR,
628
+ schedule: Schedule,
640
629
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
641
630
  opts: Opts
642
631
  ): string {
643
632
  // A uneven hour stride reads as a cadence, not a wall of hour columns: the
644
633
  // minute lead, then "cada N horas de las X a las Y".
645
- const cadence = unevenHourCadence(ir, opts);
634
+ const cadence = unevenHourCadence(schedule, opts);
646
635
 
647
636
  if (plan.form === 'wildcard') {
648
637
  if (cadence !== null) {
649
- return 'cada minuto, ' + cadence + trailingQualifier(ir, opts);
638
+ return 'cada minuto, ' + cadence + trailingQualifier(schedule, opts);
650
639
  }
651
640
 
652
- if (singleHourStep(ir.analyses.segments.hour)) {
641
+ if (singleHourStep(schedule.analyses.segments.hour)) {
653
642
  return 'cada minuto, ' +
654
- stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) +
655
- trailingQualifier(ir, opts);
643
+ stepHourSpan(stepSegment(schedule, 'hour'), opts) +
644
+ trailingQualifier(schedule, opts);
656
645
  }
657
646
 
658
- return 'cada minuto ' + hourSpanFromTimes(ir, plan.times, opts) +
659
- trailingQualifier(ir, opts);
647
+ return 'cada minuto ' + hourSpanFromTimes(schedule, plan.times, opts) +
648
+ trailingQualifier(schedule, opts);
660
649
  }
661
650
 
662
651
  const lead = plan.form === 'range' ?
663
- minuteRangeLead(ir.pattern.minute) :
664
- minutesList(ir, opts);
652
+ minuteRangeLead(schedule.pattern.minute) :
653
+ minutesList(schedule, opts);
665
654
 
666
655
  if (cadence !== null) {
667
- return lead + ', ' + cadence + trailingQualifier(ir, opts);
656
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
668
657
  }
669
658
 
670
- return lead + ', ' + atHourTimes(ir, plan.times, opts) +
671
- trailingQualifier(ir, opts);
659
+ return lead + ', ' + atHourTimes(schedule, plan.times, opts) +
660
+ trailingQualifier(schedule, opts);
672
661
  }
673
662
 
674
663
  function renderMinuteSpanAcrossHourStep(
675
- ir: IR,
664
+ schedule: Schedule,
676
665
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
677
666
  opts: Opts
678
667
  ): string {
679
- const segment = stepSegment(ir.analyses.segments.hour);
668
+ const segment = stepSegment(schedule, 'hour');
680
669
  // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
681
670
  // offset-clean step keeps its confinement / per-step phrasing.
682
- const cadence = unevenHourCadence(ir, opts);
671
+ const cadence = unevenHourCadence(schedule, opts);
683
672
 
684
673
  // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
685
674
  // or uneven step routes through minutesAcrossHours instead) and is confined.
686
675
  if (plan.form === 'wildcard') {
687
676
  return 'cada minuto, ' + stepHourSpan(segment, opts) +
688
- trailingQualifier(ir, opts);
677
+ trailingQualifier(schedule, opts);
689
678
  }
690
679
 
691
680
  // A minute list keeps the same cadence clause as the range; only its lead
692
681
  // differs ("en los minutos 5 y 30 de cada hora" vs "cada minuto del 0 al
693
682
  // 30").
694
683
  const lead = plan.form === 'list' ?
695
- minutesList(ir, opts) :
696
- minuteRangeLead(ir.pattern.minute);
684
+ minutesList(schedule, opts) :
685
+ minuteRangeLead(schedule.pattern.minute);
697
686
 
698
687
  return lead + ', ' +
699
- (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
688
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
700
689
  }
701
690
 
702
691
  // --- Hour renderers. ---
703
692
 
704
693
  function renderEveryHour(
705
- ir: IR,
694
+ schedule: Schedule,
706
695
  plan: Extract<PlanNode, {kind: 'everyHour'}>,
707
696
  opts: Opts
708
697
  ): string {
709
- return 'cada hora' + trailingQualifier(ir, opts);
698
+ return 'cada hora' + trailingQualifier(schedule, opts);
710
699
  }
711
700
 
712
701
  function renderHourRange(
713
- ir: IR,
702
+ schedule: Schedule,
714
703
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
715
704
  opts: Opts
716
705
  ): string {
717
706
  const window = hourWindow(boundedWindow(plan), opts);
718
707
 
719
708
  if (plan.minuteForm === 'wildcard') {
720
- return 'cada minuto ' + window + trailingQualifier(ir, opts);
709
+ return 'cada minuto ' + window + trailingQualifier(schedule, opts);
721
710
  }
722
711
 
723
712
  if (plan.minuteForm === 'range') {
724
- return minuteRangeLead(ir.pattern.minute) + ', ' + window +
725
- trailingQualifier(ir, opts);
713
+ return minuteRangeLead(schedule.pattern.minute) + ', ' + window +
714
+ trailingQualifier(schedule, opts);
726
715
  }
727
716
 
728
717
  // On the hour the window joins directly ("cada hora de las 9:00 a las
729
718
  // 17:00"); a discrete minute anchors its own clause first.
730
- if (ir.pattern.minute === '0') {
731
- return 'cada hora ' + window + trailingQualifier(ir, opts);
719
+ if (schedule.pattern.minute === '0') {
720
+ return 'cada hora ' + window + trailingQualifier(schedule, opts);
732
721
  }
733
722
 
734
- const lead = ir.shapes.minute === 'single' ?
735
- 'en el minuto ' + ir.pattern.minute + ' de cada hora' :
736
- minutesList(ir, opts);
723
+ const lead = schedule.shapes.minute === 'single' ?
724
+ 'en el minuto ' + schedule.pattern.minute + ' de cada hora' :
725
+ minutesList(schedule, opts);
737
726
 
738
- return lead + ', ' + window + trailingQualifier(ir, opts);
727
+ return lead + ', ' + window + trailingQualifier(schedule, opts);
739
728
  }
740
729
 
741
730
  function renderHourStep(
742
- ir: IR,
731
+ schedule: Schedule,
743
732
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
744
733
  opts: Opts
745
734
  ): string {
746
735
  // A bounded or uneven hour step reads as its endpoint-pinning cadence ("cada
747
736
  // dos horas de las 09:00 a las 17:00"); an offset-clean step keeps its bare
748
737
  // or "a partir de" cadence.
749
- const cadence = unevenHourCadence(ir, opts);
738
+ const cadence = unevenHourCadence(schedule, opts);
750
739
 
751
740
  if (cadence !== null) {
752
- return cadence + trailingQualifier(ir, opts);
741
+ return cadence + trailingQualifier(schedule, opts);
753
742
  }
754
743
 
755
- return stepHours(stepSegment(ir.analyses.segments.hour), opts) +
756
- trailingQualifier(ir, opts);
744
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
745
+ trailingQualifier(schedule, opts);
757
746
  }
758
747
 
759
748
  // The hour-range plan as a window. The close lands on the top of the final
@@ -782,8 +771,8 @@ function hourWindow(
782
771
 
783
772
  // Whether BOTH the date and weekday fields are restricted (not '*'): cron
784
773
  // fires when either condition matches, making this a date-OR-weekday union.
785
- function isDateWeekdayUnion(ir: IR): boolean {
786
- return ir.pattern.date !== '*' && ir.pattern.weekday !== '*';
774
+ function isDateWeekdayUnion(schedule: Schedule): boolean {
775
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
787
776
  }
788
777
 
789
778
  // The month lead for the unified union frame, with a trailing comma appended
@@ -791,14 +780,14 @@ function isDateWeekdayUnion(ir: IR): boolean {
791
780
  // Single month → `en enero`; range → `de enero a marzo`;
792
781
  // step/enumeration (≥2 flattened singles) → `en enero, marzo, …, y noviembre,`.
793
782
  // Wildcard month → '' (omit; frame starts with the time).
794
- function unionMonthLeadFull(ir: IR): string {
795
- if (ir.pattern.month === '*') {
783
+ function unionMonthLeadFull(schedule: Schedule): string {
784
+ if (schedule.pattern.month === '*') {
796
785
  return '';
797
786
  }
798
787
 
799
- const lead = monthPhrase(ir, monthRanged(ir) ? 'de ' : 'en ');
800
- const segments = flattenSteps(fieldSegments(ir, 'month'));
801
- 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;
802
791
 
803
792
  return isEnumeration ? lead + ',' : lead;
804
793
  }
@@ -807,19 +796,30 @@ function unionMonthLeadFull(ir: IR): string {
807
796
  // Quartz and open-step forms are self-contained; ranges use `del N al M del
808
797
  // mes`; a single date reads `el día N` under a restricted month (month is in
809
798
  // the lead) or `el N de cada mes` under a wildcard month.
810
- function domArm(ir: IR, opts: Opts): string {
811
- const date = ir.pattern.date;
799
+ function domArm(schedule: Schedule, opts: Opts): string {
800
+ const date = schedule.pattern.date;
812
801
  const quartz = quartzDatePhrase(date);
813
802
 
814
803
  if (quartz) {
815
804
  return quartz;
816
805
  }
817
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
+
818
818
  if (isOpenStep(date)) {
819
819
  return stepDates(date, opts);
820
820
  }
821
821
 
822
- const segments = fieldSegments(ir, 'date');
822
+ const segments = segmentsOf(schedule, 'date');
823
823
 
824
824
  if (segments.length === 1 && segments[0].kind === 'range') {
825
825
  return 'del ' + segments[0].bounds[0] + ' al ' +
@@ -827,7 +827,7 @@ function domArm(ir: IR, opts: Opts): string {
827
827
  }
828
828
 
829
829
  if (segments.length === 1 && segments[0].kind === 'single') {
830
- return ir.pattern.month === '*' ?
830
+ return schedule.pattern.month === '*' ?
831
831
  'el ' + segments[0].value + ' de cada mes' :
832
832
  'el día ' + segments[0].value;
833
833
  }
@@ -839,16 +839,16 @@ function domArm(ir: IR, opts: Opts): string {
839
839
  // Quartz forms are self-contained; a single weekday reads `cualquier <name>`;
840
840
  // all other forms use the same phrasing as the standalone weekday qualifier
841
841
  // (range → `de lunes a viernes`; list/step → `los domingos, …`).
842
- function dowArm(ir: IR): string {
843
- const quartz = quartzWeekdayPhrase(ir.pattern.weekday);
842
+ function dowArm(schedule: Schedule): string {
843
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
844
844
 
845
845
  if (quartz) {
846
846
  return quartz;
847
847
  }
848
848
 
849
849
  // Weekday lists display Monday-first (Sunday last); a lone range keeps its
850
- // form. The IR stays canonical (Sunday=0). The helper flattens steps.
851
- const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
850
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
851
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
852
852
  const allSingles = segments.every(function single(segment) {
853
853
  return segment.kind === 'single';
854
854
  });
@@ -865,8 +865,12 @@ function dowArm(ir: IR): string {
865
865
  }));
866
866
  }
867
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.
868
872
  if (segments.length === 1) {
869
- return weekdayRange(segments[0] as RangeNameSegment);
873
+ return 'cualquier día ' + weekdayRange(segments[0] as RangeNameSegment);
870
874
  }
871
875
 
872
876
  return joinList(segments.map(function name(segment) {
@@ -877,23 +881,23 @@ function dowArm(ir: IR): string {
877
881
  }
878
882
 
879
883
  // The `, ya sea <DOM> o <DOW>` correlative suffix for the union frame.
880
- function unionYaseaSuffix(ir: IR, opts: Opts): string {
881
- 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);
882
886
  }
883
887
 
884
888
  // "todos los días a las 9:30 y a las 17:00".
885
889
  function renderClockTimes(
886
- ir: IR,
890
+ schedule: Schedule,
887
891
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
888
892
  opts: Opts
889
893
  ): string {
890
894
  // An hour step or range (or arithmetic-progression hour list) under a single
891
895
  // pinned minute reads as a cadence or window rather than a cross-product of
892
896
  // clock times.
893
- if (ir.shapes.minute === 'single') {
894
- const minute = +ir.pattern.minute;
895
- const cadence = hourCadence(ir, minute, opts) ??
896
- 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);
897
901
 
898
902
  if (cadence !== null) {
899
903
  return cadence;
@@ -904,7 +908,7 @@ function renderClockTimes(
904
908
  return atTime(timePhrase(time.hour, time.minute, time.second, opts));
905
909
  });
906
910
 
907
- return leadingQualifier(ir, opts) + groupClockTimes(phrases);
911
+ return leadingQualifier(schedule, opts) + groupClockTimes(phrases);
908
912
  }
909
913
 
910
914
  // The genitive clock-time list for a minute-0 compose-seconds confinement:
@@ -1230,7 +1234,7 @@ function groupClockTimesByArticle(phrases: string[]): string {
1230
1234
  // Compact form past the enumeration cap: a single minute folds into
1231
1235
  // per-segment hour windows; a minute list leads with its own clause.
1232
1236
  function renderCompactClockTimes(
1233
- ir: IR,
1237
+ schedule: Schedule,
1234
1238
  plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
1235
1239
  opts: Opts
1236
1240
  ): string {
@@ -1238,39 +1242,44 @@ function renderCompactClockTimes(
1238
1242
  // An hour step or range (or arithmetic-progression hour list) under the
1239
1243
  // single pinned minute reads as a cadence or window, not a wall of clock
1240
1244
  // times. (Returns null for an irregular list, which keeps folding below.)
1241
- const cadence = hourCadence(ir, plan.minute, opts) ??
1242
- hourRangeCadence(ir, plan.minute, opts);
1245
+ const cadence = hourCadence(schedule, plan.minute, opts) ??
1246
+ hourRangeCadence(schedule, plan.minute, opts);
1243
1247
 
1244
1248
  if (cadence !== null) {
1245
1249
  return cadence;
1246
1250
  }
1247
1251
 
1248
- const ranged = hourSegments(ir).some(function range(segment) {
1252
+ const ranged = segmentsOf(schedule, 'hour').some(function range(segment) {
1249
1253
  return segment.kind === 'range';
1250
1254
  });
1251
1255
 
1252
1256
  // A folded contiguous hour range reads with the hourly cadence ("cada
1253
1257
  // hora de las 9:00 a las 20:00 y a las 22:00"), not "todos los días".
1254
- if (ranged && !ir.analyses.clockSecond) {
1258
+ if (ranged && !schedule.analyses.clockSecond) {
1255
1259
  return 'cada hora ' +
1256
- hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts) +
1257
- trailingQualifier(ir, opts);
1260
+ hourSegmentTimes(
1261
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1262
+ ) +
1263
+ trailingQualifier(schedule, opts);
1258
1264
  }
1259
1265
 
1260
- return leadingQualifier(ir, opts) +
1261
- hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1266
+ return leadingQualifier(schedule, opts) +
1267
+ hourSegmentTimes(
1268
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1269
+ );
1262
1270
  }
1263
1271
 
1264
1272
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1265
1273
  // of clock-time columns.
1266
- const cadence = unevenHourCadence(ir, opts);
1274
+ const cadence = unevenHourCadence(schedule, opts);
1267
1275
  const phrase = cadence ?
1268
- minutesList(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
1269
- minutesList(ir, opts) + ', ' +
1270
- 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);
1271
1280
 
1272
- return ir.analyses.clockSecond ?
1273
- secondsLeadClause(ir, opts) + ', ' + phrase :
1281
+ return schedule.analyses.clockSecond ?
1282
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
1274
1283
  phrase;
1275
1284
  }
1276
1285
 
@@ -1312,21 +1321,17 @@ const renderers = {
1312
1321
  function renderStride(stride: Stride, opts: Opts): string {
1313
1322
  const {interval, start, last, cycle, unit, anchor} = stride;
1314
1323
  const cadence = 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1315
- const tiles = cycle % interval === 0;
1316
-
1317
- if (start === 0 && tiles) {
1318
- return cadence;
1319
- }
1320
1324
 
1321
1325
  // A context that supplies its own trailing scope passes an empty anchor, so
1322
1326
  // the cadence keeps its endpoints but drops the "de cada <anchor>" tail.
1323
1327
  const tail = anchor ? ' de cada ' + anchor : '';
1324
1328
 
1325
- if (start < interval && tiles) {
1326
- return cadence + ' a partir del ' + unit + ' ' + start + tail;
1327
- }
1328
-
1329
- 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
+ });
1330
1335
  }
1331
1336
 
1332
1337
  // "cada 15 minutos", "en los minutos 5, 20 y 35 de cada hora", or
@@ -1366,9 +1371,9 @@ function stepCycle60(
1366
1371
 
1367
1372
  // Speak a minute/second field's enumerated fires as a step cadence when they
1368
1373
  // form an arithmetic progression long enough to beat the list (the core
1369
- // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
1370
- // the renderer recognizes the progression). Returns null for a non-progression
1371
- // 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.
1372
1377
  function strideFromSegments(
1373
1378
  segments: Segment[],
1374
1379
  unit: string,
@@ -1383,21 +1388,6 @@ function strideFromSegments(
1383
1388
  null;
1384
1389
  }
1385
1390
 
1386
- // The sorted numeric values a field's segments cover, or null if any segment
1387
- // is not a discrete single (a range or sub-step is not a plain fire list).
1388
- function singleValues(segments: Segment[]): number[] | null {
1389
- const values: number[] = [];
1390
-
1391
- for (const segment of segments) {
1392
- if (segment.kind !== 'single') {
1393
- return null;
1394
- }
1395
-
1396
- values.push(+segment.value);
1397
- }
1398
-
1399
- return values;
1400
- }
1401
1391
 
1402
1392
  // "cada seis horas", "a las 9:00, a las 11:00 y a la 1:00", or "cada
1403
1393
  // cinco horas a partir de las 2:00".
@@ -1438,29 +1428,13 @@ function hourStrideCadence(
1438
1428
  ): string {
1439
1429
  const {start, interval, last} = stride;
1440
1430
  const cadence = 'cada ' + numero(interval, opts) + ' horas';
1441
- const tiles = 24 % interval === 0;
1442
-
1443
- if (start === 0 && tiles) {
1444
- return cadence;
1445
- }
1446
1431
 
1447
- if (start < interval && tiles) {
1448
- return cadence + ' a partir de ' + timePhrase(start, 0, null, opts);
1449
- }
1450
-
1451
- return cadence + ' de ' + timePhrase(start, 0, null, opts) + ' a ' +
1452
- timePhrase(last, 0, null, opts);
1453
- }
1454
-
1455
- // Whether an hour stride wraps the day cleanly from within its first interval
1456
- // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1457
- // stride has no distinct endpoint and keeps its bare or "a partir de" cadence.
1458
- // Every other stride — a uneven interval, or one starting at or past its
1459
- // interval (a bounded `a-b/n`) — is a bounded set the cadence pins the ends of.
1460
- function offsetCleanStride(
1461
- stride: {start: number; interval: number}
1462
- ): boolean {
1463
- return stride.start < stride.interval && 24 % stride.interval === 0;
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
+ });
1464
1438
  }
1465
1439
 
1466
1440
  // The bounded cadence for an hour stride that pins both clock-time endpoints,
@@ -1470,8 +1444,8 @@ function offsetCleanStride(
1470
1444
  // ("…, cada cinco horas de las 00:00 a las 20:00") than as a wall of clock
1471
1445
  // times. An offset-clean stride keeps its existing confinement form, so only
1472
1446
  // the endpoint-bearing case routes here.
1473
- function unevenHourCadence(ir: IR, opts: Opts): string | null {
1474
- const stride = hourStride(ir);
1447
+ function unevenHourCadence(schedule: Schedule, opts: Opts): string | null {
1448
+ const stride = hourStride(schedule);
1475
1449
 
1476
1450
  if (!stride || offsetCleanStride(stride)) {
1477
1451
  return null;
@@ -1480,51 +1454,16 @@ function unevenHourCadence(ir: IR, opts: Opts): string | null {
1480
1454
  return hourStrideCadence(stride, opts);
1481
1455
  }
1482
1456
 
1483
- // An hour list's arithmetic progression, or null when its values are not a
1484
- // step the renderer should speak as a cadence. The core rewrites a uneven hour
1485
- // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
1486
- // literal fire list, indistinguishable in the IR from a hand-written list; the
1487
- // renderer recovers the cadence from the values. A progression starting at zero
1488
- // is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is
1489
- // only a step when it is too long to be a deliberate clock-time list (e.g. 9,17
1490
- // is two named times, not a cadence). Interval one is a plain range, never a
1491
- // step.
1492
- function hourListStride(
1493
- values: number[]
1494
- ): {start: number; interval: number; last: number} | null {
1495
- if (values.length < 2) {
1496
- return null;
1497
- }
1498
-
1499
- const interval = values[1] - values[0];
1500
-
1501
- if (interval < 2) {
1502
- return null;
1503
- }
1504
-
1505
- for (let i = 2; i < values.length; i += 1) {
1506
- if (values[i] - values[i - 1] !== interval) {
1507
- return null;
1508
- }
1509
- }
1510
-
1511
- if (values[0] !== 0 && values.length < 5) {
1512
- return null;
1513
- }
1514
-
1515
- return {interval, last: values[values.length - 1], start: values[0]};
1516
- }
1517
-
1518
1457
  // The hour field's stride, or null when the hour is not a cadence: a step
1519
1458
  // segment yields its {start, interval, last} directly; an all-single hour
1520
1459
  // list yields one only when its values form a step progression (so an irregular
1521
- // 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
1522
1461
  // recognizes the stride and speaks it as a cadence instead of the clock-time
1523
1462
  // cross-product.
1524
1463
  function hourStride(
1525
- ir: IR
1464
+ schedule: Schedule
1526
1465
  ): {start: number; interval: number; last: number} | null {
1527
- const segments = fieldSegments(ir, 'hour');
1466
+ const segments = segmentsOf(schedule, 'hour');
1528
1467
 
1529
1468
  if (segments.length === 1 && segments[0].kind === 'step') {
1530
1469
  const segment = segments[0];
@@ -1551,8 +1490,8 @@ function hourStride(
1551
1490
  // The second's status against a pinned minute: a wildcard or sub-minute step
1552
1491
  // fills the minute (a "durante un minuto" frame at minute 0); a single 0 is
1553
1492
  // just the top of the minute (no clause); anything else needs its own clause.
1554
- function subMinuteSecond(ir: IR): boolean {
1555
- return ir.pattern.second === '*' || ir.shapes.second === 'step';
1493
+ function subMinuteSecond(schedule: Schedule): boolean {
1494
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1556
1495
  }
1557
1496
 
1558
1497
  // The lead clause for an hour-cadence rendering: the second and the pinned
@@ -1562,24 +1501,26 @@ function subMinuteSecond(ir: IR): boolean {
1562
1501
  // minuto" frame (the whole minute-0 window). A non-zero minute is a real clock
1563
1502
  // minute: the second leads with its own clause (if any), then the minute reads
1564
1503
  // "en el minuto M".
1565
- function hourCadenceLead(ir: IR, minute: number, opts: Opts): string {
1504
+ function hourCadenceLead(
1505
+ schedule: Schedule, minute: number, opts: Opts
1506
+ ): string {
1566
1507
  if (minute === 0) {
1567
- if (subMinuteSecond(ir)) {
1568
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto';
1508
+ if (subMinuteSecond(schedule)) {
1509
+ return secondsClause(schedule, 'minuto', opts) + ' durante un minuto';
1569
1510
  }
1570
1511
 
1571
- return secondsClause(ir, 'hora', opts);
1512
+ return secondsClause(schedule, 'hora', opts);
1572
1513
  }
1573
1514
 
1574
1515
  const minutePhrase = 'en el minuto ' + minute;
1575
1516
 
1576
1517
  // A single 0 second is just the top of the minute, so the minute leads
1577
1518
  // alone; any other second prefixes its own clause.
1578
- if (ir.pattern.second === '0') {
1519
+ if (schedule.pattern.second === '0') {
1579
1520
  return minutePhrase;
1580
1521
  }
1581
1522
 
1582
- return secondsClause(ir, 'minuto', opts) + ', ' + minutePhrase;
1523
+ return secondsClause(schedule, 'minuto', opts) + ', ' + minutePhrase;
1583
1524
  }
1584
1525
 
1585
1526
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1590,9 +1531,11 @@ function hourCadenceLead(ir: IR, minute: number, opts: Opts): string {
1590
1531
  // enumeration is no longer than the cadence: a meaningful second makes every
1591
1532
  // clock time three digit-groups, so any stride is worth compacting; otherwise
1592
1533
  // the stride must exceed the clock-time cap, the same point at which the core
1593
- // itself stops enumerating. Renderer-only; the IR is unchanged.
1594
- function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1595
- 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);
1596
1539
 
1597
1540
  if (!stride) {
1598
1541
  return null;
@@ -1605,7 +1548,7 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1605
1548
  // or "a partir de" form is no shorter than the list. A bounded or uneven
1606
1549
  // stride has no clean wrap, so its endpoint-pinning cadence ("cada cinco
1607
1550
  // horas de las 00:00 a las 20:00") reads better however short.
1608
- if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1551
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1609
1552
  offsetCleanStride(stride)) {
1610
1553
  return null;
1611
1554
  }
@@ -1614,31 +1557,31 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1614
1557
  // stride is a confinement, not a juxtaposed cadence: it reads "durante un
1615
1558
  // minuto, durante las horas pares", reusing the hour-step confinement idiom
1616
1559
  // so the minute-0 window is never heard as the bare hour cadence.
1617
- const confinement = minute === 0 && subMinuteSecond(ir) &&
1618
- cleanStrideSegment(ir);
1560
+ const confinement = minute === 0 && subMinuteSecond(schedule) &&
1561
+ cleanStrideSegment(schedule);
1619
1562
 
1620
1563
  if (confinement) {
1621
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1622
- stepHourSpan(confinement, opts) + trailingQualifier(ir, opts);
1564
+ return secondsClause(schedule, 'minuto', opts) + ' durante un minuto, ' +
1565
+ stepHourSpan(confinement, opts) + trailingQualifier(schedule, opts);
1623
1566
  }
1624
1567
 
1625
1568
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1626
1569
  // lead clause to fold in, so the bounded cadence stands on its own ("cada
1627
1570
  // cinco horas de las 00:00 a las 20:00").
1628
- if (minute === 0 && ir.pattern.second === '0') {
1629
- return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1571
+ if (minute === 0 && schedule.pattern.second === '0') {
1572
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1630
1573
  }
1631
1574
 
1632
- return hourCadenceLead(ir, minute, opts) + ', ' +
1633
- hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1575
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1576
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1634
1577
  }
1635
1578
 
1636
1579
  // The hour step segment when the hour is a clean stride es renders as a
1637
1580
  // confinement phrase ("durante las horas pares"); null otherwise (an offset or
1638
1581
  // bounded step, an uneven stride, or an arithmetic-progression list, which
1639
1582
  // keep the bounded cadence form).
1640
- function cleanStrideSegment(ir: IR): StepSegment | null {
1641
- const segments = fieldSegments(ir, 'hour');
1583
+ function cleanStrideSegment(schedule: Schedule): StepSegment | null {
1584
+ const segments = segmentsOf(schedule, 'hour');
1642
1585
  const segment = segments.length === 1 && segments[0];
1643
1586
 
1644
1587
  if (!segment || segment.kind !== 'step' ||
@@ -1653,8 +1596,8 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1653
1596
  // range — and so forms a window rather than a cross-product of clock times.
1654
1597
  // A pure single-value list (9,17) has no range to span and still enumerates;
1655
1598
  // a step is handled by hourStride/hourCadence.
1656
- function hasHourWindow(ir: IR): boolean {
1657
- return hourSegments(ir).some(function range(segment) {
1599
+ function hasHourWindow(schedule: Schedule): boolean {
1600
+ return segmentsOf(schedule, 'hour').some(function range(segment) {
1658
1601
  return segment.kind === 'range';
1659
1602
  });
1660
1603
  }
@@ -1666,9 +1609,12 @@ function hasHourWindow(ir: IR): boolean {
1666
1609
  // times. The hour-RANGE analog of hourCadence. Returns null when the hour has
1667
1610
  // no range, when the minute is non-zero (a real clock minute the existing
1668
1611
  // window form already speaks), or when a plain :00 set carries no clause.
1669
- // Renderer-only; the IR is unchanged.
1670
- function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1671
- 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') {
1672
1618
  return null;
1673
1619
  }
1674
1620
 
@@ -1678,14 +1624,15 @@ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1678
1624
  // ("cada hora de las 09:00 a las 17:00") so the confinement is never heard
1679
1625
  // as it — the hour-range analog of "durante un minuto, durante las horas
1680
1626
  // pares".
1681
- if (subMinuteSecond(ir)) {
1682
- return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1683
- 'durante las horas ' + hourSegmentTimes(ir, 0, null, opts) +
1684
- 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);
1685
1631
  }
1686
1632
 
1687
- return hourCadenceLead(ir, minute, opts) + ', ' +
1688
- hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1633
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1634
+ hourSegmentTimes(schedule, 0, null, opts) +
1635
+ trailingQualifier(schedule, opts);
1689
1636
  }
1690
1637
 
1691
1638
  // --- Hour-time phrasing. ---
@@ -1696,8 +1643,8 @@ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1696
1643
  // hour "de la hora de las HH:00" (the idiom a wildcard minute already uses).
1697
1644
  // Used by the compact-clock non-fold path, where the minute is a step or list
1698
1645
  // (a single-value minute keeps its real "a las HH:MM" clock time elsewhere).
1699
- function hourContextTimes(ir: IR, opts: Opts): string {
1700
- const segments = hourSegments(ir);
1646
+ function hourContextTimes(schedule: Schedule, opts: Opts): string {
1647
+ const segments = segmentsOf(schedule, 'hour');
1701
1648
 
1702
1649
  // Collect the point hours (singles and step fires) — a range stays a window.
1703
1650
  const points: number[] = [];
@@ -1765,7 +1712,7 @@ function atTimes(hours: number[], opts: Opts): string[] {
1765
1712
  // The hour times accompanying a lead clause: "a las 9:00 y a las 17:00",
1766
1713
  // with long expansions rendered segment by segment.
1767
1714
  function atHourTimes(
1768
- ir: IR,
1715
+ schedule: Schedule,
1769
1716
  times: HourTimesPlan,
1770
1717
  opts: Opts
1771
1718
  ): string {
@@ -1773,25 +1720,27 @@ function atHourTimes(
1773
1720
  return groupClockTimesByArticle(atTimes(times.fires, opts));
1774
1721
  }
1775
1722
 
1776
- return hourSegmentTimes(ir, 0, null, opts);
1723
+ return hourSegmentTimes(schedule, 0, null, opts);
1777
1724
  }
1778
1725
 
1779
1726
  // The active hours of a confined cadence: a few hours read as windows; many
1780
1727
  // read better as a compact list ("durante las horas de las 9, 11, 13, 15 y
1781
1728
  // 17") than as a sprawl of windows.
1782
- function hourSpanFromTimes(ir: IR, times: HourTimesPlan, opts: Opts): string {
1729
+ function hourSpanFromTimes(
1730
+ schedule: Schedule, times: HourTimesPlan, opts: Opts
1731
+ ): string {
1783
1732
  if (times.kind === 'fires' && times.fires.length > 3) {
1784
1733
  return 'durante las horas ' + hourSpanList(times.fires, opts);
1785
1734
  }
1786
1735
 
1787
- return hourWindowsFromTimes(ir, times, opts);
1736
+ return hourWindowsFromTimes(schedule, times, opts);
1788
1737
  }
1789
1738
 
1790
1739
  // Each fire hour as its own one-hour window: "de las 9:00 a las 9:59 y de
1791
1740
  // las 17:00 a las 17:59". Spanish prefers this to the English "during the
1792
1741
  // 9 a.m. and 5 p.m. hours" shape.
1793
1742
  function hourWindowsFromTimes(
1794
- ir: IR,
1743
+ schedule: Schedule,
1795
1744
  times: HourTimesPlan,
1796
1745
  opts: Opts
1797
1746
  ): string {
@@ -1801,7 +1750,7 @@ function hourWindowsFromTimes(
1801
1750
  }));
1802
1751
  }
1803
1752
 
1804
- return joinList(hourSegments(ir).map(function window(segment) {
1753
+ return joinList(segmentsOf(schedule, 'hour').map(function window(segment) {
1805
1754
  if (segment.kind === 'range') {
1806
1755
  return timeRange({hour: +segment.bounds[0], minute: 0},
1807
1756
  {hour: +segment.bounds[1], minute: 59}, opts);
@@ -1821,7 +1770,7 @@ function hourWindowsFromTimes(
1821
1770
  // (and optional second) folded into each: "de las 9:30 a las 20:30 y también
1822
1771
  // a las 22:30" when an isolated point-time follows a range.
1823
1772
  function hourSegmentTimes(
1824
- ir: IR,
1773
+ schedule: Schedule,
1825
1774
  minute: number,
1826
1775
  second: number | null | undefined,
1827
1776
  opts: Opts
@@ -1830,7 +1779,7 @@ function hourSegmentTimes(
1830
1779
  const pieces: string[] = [];
1831
1780
  const fromRange: boolean[] = [];
1832
1781
 
1833
- hourSegments(ir).forEach(function clock(segment) {
1782
+ segmentsOf(schedule, 'hour').forEach(function clock(segment) {
1834
1783
  if (segment.kind === 'step') {
1835
1784
  segment.fires.forEach(function each(hour) {
1836
1785
  pieces.push(atTime(timePhrase(hour, minute, second, opts)));
@@ -2021,23 +1970,23 @@ function dayPeriod(hour: number, opts: Opts): string {
2021
1970
  // lunes ", "el 13 de cada mes ", "de lunes a viernes ".
2022
1971
  // Date-OR-weekday unions skip this entirely — the unified frame in `render`
2023
1972
  // handles the month lead and day-level suffix.
2024
- function leadingQualifier(ir: IR, opts: Opts): string {
2025
- const pattern = ir.pattern;
1973
+ function leadingQualifier(schedule: Schedule, opts: Opts): string {
1974
+ const pattern = schedule.pattern;
2026
1975
 
2027
1976
  if (pattern.date !== '*' && pattern.weekday !== '*') {
2028
1977
  return '';
2029
1978
  }
2030
1979
 
2031
1980
  if (pattern.date !== '*') {
2032
- return datePhrase(ir, opts) + ' ';
1981
+ return datePhrase(schedule, opts) + ' ';
2033
1982
  }
2034
1983
 
2035
1984
  if (pattern.weekday !== '*') {
2036
- return weekdayQualifier(ir) + monthScope(ir) + ' ';
1985
+ return weekdayQualifier(schedule) + monthScope(schedule) + ' ';
2037
1986
  }
2038
1987
 
2039
1988
  if (pattern.month !== '*') {
2040
- return 'todos los días ' + monthPhrase(ir, 'de ') + ' ';
1989
+ return 'todos los días ' + monthPhrase(schedule, 'de ') + ' ';
2041
1990
  }
2042
1991
 
2043
1992
  return 'todos los días ';
@@ -2047,23 +1996,23 @@ function leadingQualifier(ir: IR, opts: Opts): string {
2047
1996
  // de cada mes". Empty when no day-level field is set.
2048
1997
  // Date-OR-weekday unions skip this entirely — the unified frame in `render`
2049
1998
  // handles the month lead and day-level suffix.
2050
- function trailingQualifier(ir: IR, opts: Opts): string {
2051
- const pattern = ir.pattern;
1999
+ function trailingQualifier(schedule: Schedule, opts: Opts): string {
2000
+ const pattern = schedule.pattern;
2052
2001
 
2053
2002
  if (pattern.date !== '*' && pattern.weekday !== '*') {
2054
2003
  return '';
2055
2004
  }
2056
2005
 
2057
2006
  if (pattern.date !== '*') {
2058
- return ' ' + datePhrase(ir, opts);
2007
+ return ' ' + datePhrase(schedule, opts);
2059
2008
  }
2060
2009
 
2061
2010
  if (pattern.weekday !== '*') {
2062
- return ' ' + weekdayQualifier(ir) + monthScope(ir);
2011
+ return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
2063
2012
  }
2064
2013
 
2065
2014
  if (pattern.month !== '*') {
2066
- return ' ' + monthPhrase(ir, 'en ');
2015
+ return ' ' + monthPhrase(schedule, 'en ');
2067
2016
  }
2068
2017
 
2069
2018
  return '';
@@ -2072,24 +2021,24 @@ function trailingQualifier(ir: IR, opts: Opts): string {
2072
2021
  // The date qualifier: "el 13 de junio", "los días 1 y 15 de cada mes",
2073
2022
  // "del 1 al 15 de cada mes", or a Quartz phrase. A foldable single year
2074
2023
  // joins the date ("el 25 de diciembre de 2030").
2075
- function datePhrase(ir: IR, opts: Opts): string {
2076
- const pattern = ir.pattern;
2024
+ function datePhrase(schedule: Schedule, opts: Opts): string {
2025
+ const pattern = schedule.pattern;
2077
2026
 
2078
2027
  if (quartzDatePhrase(pattern.date) || isOpenStep(pattern.date)) {
2079
- return dateClause(ir, '', opts) + monthScope(ir);
2028
+ return dateClause(schedule, '', opts) + monthScope(schedule);
2080
2029
  }
2081
2030
 
2082
- return dateClause(ir, dateMonthPart(ir), opts);
2031
+ return dateClause(schedule, dateMonthPart(schedule), opts);
2083
2032
  }
2084
2033
 
2085
2034
  // The date words with a caller-chosen month part. Quartz phrases and open
2086
2035
  // steps are self-contained and ignore the month part.
2087
2036
  function dateClause(
2088
- ir: IR,
2037
+ schedule: Schedule,
2089
2038
  monthPart: string,
2090
2039
  opts: Opts
2091
2040
  ): string {
2092
- const pattern = ir.pattern;
2041
+ const pattern = schedule.pattern;
2093
2042
  const quartz = quartzDatePhrase(pattern.date);
2094
2043
 
2095
2044
  if (quartz) {
@@ -2100,25 +2049,25 @@ function dateClause(
2100
2049
  return stepDates(pattern.date, opts);
2101
2050
  }
2102
2051
 
2103
- const segments = fieldSegments(ir, 'date');
2052
+ const segments = segmentsOf(schedule, 'date');
2104
2053
 
2105
2054
  if (segments.length === 1 && segments[0].kind === 'range') {
2106
2055
  return 'del ' + segments[0].bounds[0] + ' al ' +
2107
- segments[0].bounds[1] + monthPart + foldedYear(ir);
2056
+ segments[0].bounds[1] + monthPart + foldedYear(schedule);
2108
2057
  }
2109
2058
 
2110
2059
  if (segments.length === 1 && segments[0].kind === 'single') {
2111
- return 'el ' + segments[0].value + monthPart + foldedYear(ir);
2060
+ return 'el ' + segments[0].value + monthPart + foldedYear(schedule);
2112
2061
  }
2113
2062
 
2114
2063
  return 'los días ' + joinList(segmentWords(segments)) + monthPart +
2115
- foldedYear(ir);
2064
+ foldedYear(schedule);
2116
2065
  }
2117
2066
 
2118
2067
  // Whether the month field contains a range segment.
2119
- function monthRanged(ir: IR): boolean {
2120
- return ir.pattern.month !== '*' &&
2121
- fieldSegments(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) {
2122
2071
  return segment.kind === 'range';
2123
2072
  });
2124
2073
  }
@@ -2128,21 +2077,21 @@ function monthRanged(ir: IR): boolean {
2128
2077
  // "el 1 de junio a septiembre" parses as "(el 1 de junio) a septiembre" —
2129
2078
  // so it scopes the date instead ("el 1 de cada mes, de junio a
2130
2079
  // septiembre").
2131
- function dateMonthPart(ir: IR): string {
2132
- if (ir.pattern.month === '*') {
2080
+ function dateMonthPart(schedule: Schedule): string {
2081
+ if (schedule.pattern.month === '*') {
2133
2082
  return ' de cada mes';
2134
2083
  }
2135
2084
 
2136
- if (monthRanged(ir)) {
2137
- return ' de cada mes, ' + monthPhrase(ir, 'de ');
2085
+ if (monthRanged(schedule)) {
2086
+ return ' de cada mes, ' + monthPhrase(schedule, 'de ');
2138
2087
  }
2139
2088
 
2140
- return ' ' + monthPhrase(ir, 'de ');
2089
+ return ' ' + monthPhrase(schedule, 'de ');
2141
2090
  }
2142
2091
 
2143
2092
  // "de 2030" when a single year can fold into a calendar date.
2144
- function foldedYear(ir: IR): string {
2145
- const yearField = ir.pattern.year;
2093
+ function foldedYear(schedule: Schedule): string {
2094
+ const yearField = schedule.pattern.year;
2146
2095
 
2147
2096
  if (yearField === '*' || yearField.indexOf('/') !== -1 ||
2148
2097
  yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
@@ -2198,16 +2147,16 @@ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
2198
2147
  // miércoles y viernes". No "todos" prefix: the plural definite article
2199
2148
  // ("los lunes") already conveys "every Monday" in Spanish, unlike "todos
2200
2149
  // los días", where "los días" alone does not mean "every day".
2201
- function weekdayQualifier(ir: IR): string {
2202
- const quartz = quartzWeekdayPhrase(ir.pattern.weekday);
2150
+ function weekdayQualifier(schedule: Schedule): string {
2151
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
2203
2152
 
2204
2153
  if (quartz) {
2205
2154
  return quartz;
2206
2155
  }
2207
2156
 
2208
2157
  // Weekday lists display Monday-first (Sunday last); a lone range keeps its
2209
- // form. The IR stays canonical (Sunday=0). The helper flattens steps.
2210
- const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
2158
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
2159
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
2211
2160
  const allSingles = segments.every(function single(segment) {
2212
2161
  return segment.kind === 'single';
2213
2162
  });
@@ -2259,8 +2208,8 @@ function flattenSteps(segments: Segment[]): NameSegment[] {
2259
2208
  // as one unit, so in mixed lists every piece repeats its preposition
2260
2209
  // ("en enero y de marzo a junio") — a bare "enero y marzo a junio" parses
2261
2210
  // as "(enero y marzo) a junio".
2262
- function monthPhrase(ir: IR, lead: string): string {
2263
- const segments = flattenSteps(fieldSegments(ir, 'month'));
2211
+ function monthPhrase(schedule: Schedule, lead: string): string {
2212
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
2264
2213
  const ranged = segments.some(function range(segment) {
2265
2214
  return segment.kind === 'range';
2266
2215
  });
@@ -2286,15 +2235,39 @@ function monthPhrase(ir: IR, lead: string): string {
2286
2235
  // junio"). A ranged scope sets off with a comma ("el último día del mes,
2287
2236
  // de junio a septiembre") — gluing "de junio" after "del mes"
2288
2237
  // garden-paths.
2289
- function monthScope(ir: IR): string {
2290
- if (ir.pattern.month === '*') {
2238
+ function monthScope(schedule: Schedule): string {
2239
+ if (schedule.pattern.month === '*') {
2291
2240
  return '';
2292
2241
  }
2293
2242
 
2294
- return (monthRanged(ir) ? ', ' : ' ') + monthPhrase(ir, 'de ');
2243
+ return (monthRanged(schedule) ? ', ' : ' ') + monthPhrase(schedule, 'de ');
2295
2244
  }
2296
2245
 
2297
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
+
2298
2271
  function stepDates(dateField: string, opts: Opts): string {
2299
2272
  const parts = dateField.split('/');
2300
2273
  let phrase = 'cada ' + numero(+parts[1], opts) + ' días del mes';
@@ -2312,10 +2285,10 @@ function stepDates(dateField: string, opts: Opts): string {
2312
2285
  // "en 2030, 2031 y 2032", "cada dos años desde 2030".
2313
2286
  function applyYear(
2314
2287
  description: string,
2315
- ir: IR,
2288
+ schedule: Schedule,
2316
2289
  opts: Opts
2317
2290
  ): string {
2318
- const yearField = ir.pattern.year;
2291
+ const yearField = schedule.pattern.year;
2319
2292
 
2320
2293
  if (yearField === '*') {
2321
2294
  return description;
@@ -2326,7 +2299,7 @@ function applyYear(
2326
2299
  }
2327
2300
 
2328
2301
  // A foldable single year already joined its date in datePhrase.
2329
- if (foldedYear(ir) && ir.pattern.date !== '*') {
2302
+ if (foldedYear(schedule) && schedule.pattern.date !== '*') {
2330
2303
  return description;
2331
2304
  }
2332
2305
 
@@ -2423,13 +2396,8 @@ function monthName(token: NameToken): string {
2423
2396
  return monthNames[+token] as string;
2424
2397
  }
2425
2398
 
2426
- // Whether a canonical field value is an open step (`*/n` or `a/n`).
2427
- function isOpenStep(field: string): boolean {
2428
- return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
2429
- field.indexOf(',') === -1;
2430
- }
2431
2399
 
2432
- // The Spanish language module: the IR renderer plus the language-owned
2400
+ // The Spanish language module: the Schedule renderer plus the language-owned
2433
2401
  // strings and option normalization.
2434
2402
  const es: Language<SpanishStyle> = {
2435
2403
  describe,