cronli5 0.3.4 → 0.8.0

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 (51) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +63 -13
  3. package/cronli5.min.js +2 -2
  4. package/dist/cronli5.cjs +72 -9
  5. package/dist/cronli5.js +72 -9
  6. package/dist/lang/de.cjs +14 -6
  7. package/dist/lang/de.js +14 -6
  8. package/dist/lang/en.cjs +14 -6
  9. package/dist/lang/en.js +14 -6
  10. package/dist/lang/es.cjs +14 -6
  11. package/dist/lang/es.js +14 -6
  12. package/dist/lang/fi.cjs +14 -6
  13. package/dist/lang/fi.js +14 -6
  14. package/dist/lang/fr.cjs +1211 -0
  15. package/dist/lang/fr.js +1187 -0
  16. package/dist/lang/pt.cjs +1592 -0
  17. package/dist/lang/pt.js +1568 -0
  18. package/dist/lang/zh.cjs +58 -9
  19. package/dist/lang/zh.js +58 -9
  20. package/package.json +13 -2
  21. package/src/core/cadence.ts +25 -12
  22. package/src/core/index.ts +7 -3
  23. package/src/core/quartz.ts +97 -0
  24. package/src/core/schedule.ts +1 -0
  25. package/src/core/specs.ts +2 -2
  26. package/src/cronli5.ts +20 -3
  27. package/src/lang/de/index.ts +3 -2
  28. package/src/lang/en/index.ts +3 -2
  29. package/src/lang/es/index.ts +3 -2
  30. package/src/lang/fi/index.ts +3 -2
  31. package/src/lang/fr/dialects.ts +49 -0
  32. package/src/lang/fr/index.ts +2116 -0
  33. package/src/lang/fr/notes.md +280 -0
  34. package/src/lang/fr/status.json +8 -0
  35. package/src/lang/pt/dialects.ts +56 -0
  36. package/src/lang/pt/index.ts +2804 -0
  37. package/src/lang/pt/notes.md +199 -0
  38. package/src/lang/pt/status.json +8 -0
  39. package/src/lang/zh/index.ts +61 -5
  40. package/src/lang/zh/notes.md +16 -4
  41. package/src/lang/zh/status.json +10 -1
  42. package/src/types.ts +44 -0
  43. package/types/core/cadence.d.ts +1 -0
  44. package/types/core/quartz.d.ts +4 -0
  45. package/types/core/schedule.d.ts +1 -0
  46. package/types/cronli5.d.ts +4 -4
  47. package/types/lang/fr/dialects.d.ts +11 -0
  48. package/types/lang/fr/index.d.ts +4 -0
  49. package/types/lang/pt/dialects.d.ts +13 -0
  50. package/types/lang/pt/index.d.ts +4 -0
  51. package/types/types.d.ts +39 -0
@@ -0,0 +1,2804 @@
1
+ // The Portuguese language module: renders an analyzed cron pattern (the
2
+ // Schedule produced by core `analyze`) as natural Brazilian Portuguese.
3
+ // Anchored to the pt-BR norm (VOLP / Academia Brasileira de Letras, plus
4
+ // cronstrue `pt_BR`); see notes.md for the decisions and trade-offs.
5
+ //
6
+ // pt is sibling-derived from es (docs/i18n-design.md §7, the language
7
+ // pipeline): it ports the Spanish module's STRUCTURE — the plan override, the
8
+ // OR-union frame, the parity predicates, the re-strategies, the dialect
9
+ // mechanism — and translates the lexicon, then diverges where Portuguese
10
+ // grammar genuinely differs: preposition+article contraction (do/da/no/na/à),
11
+ // gender agreement (feminine weekdays, gendered ordinals and determiners), the
12
+ // "toda X" single-weekday recurrence, "na 2ª segunda-feira", no comma before
13
+ // "e", and the ordinal "dia 1º". A language never imports another (this is a
14
+ // copy-and-translate of es, not an import); the only shared dependency is core.
15
+
16
+ import {clockDigits, numeral} from '../../core/format.js';
17
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
18
+ import {isOpenStep} from '../../core/shapes.js';
19
+ import {
20
+ arithmeticStep, hourListStride, offsetCleanStride,
21
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
22
+ } from '../../core/cadence.js';
23
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
24
+ import {toFieldNumber} from '../../core/util.js';
25
+ import type {Cronli5Options} from '../../types.js';
26
+ import type {
27
+ HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
28
+ Segment
29
+ } from '../../core/schedule.js';
30
+ import {resolveDialect, type PortugueseStyle} from './dialects.js';
31
+
32
+ // Normalized options carrying Portuguese's own style shape.
33
+ type Opts = NormalizedOptions<PortugueseStyle>;
34
+
35
+ // The erased renderer signature the dispatch table maps to.
36
+ type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
37
+
38
+ // A `step` segment, narrowed from the discriminated `Segment` union.
39
+ type StepSegment = Extract<Segment, {kind: 'step'}>;
40
+
41
+ // A step cadence to phrase: the `interval` repeats over a `cycle`-long field
42
+ // (60 for minute/second), running from `start` to `last`. `unit` is the
43
+ // singular noun and `anchor` the larger unit the values count against. When
44
+ // `anchor` is empty the caller supplies its own trailing scope, so the cadence
45
+ // drops the "de cada <anchor>" tail.
46
+ interface Stride {
47
+ interval: number;
48
+ start: number;
49
+ last: number;
50
+ cycle: number;
51
+ unit: string;
52
+ anchor: string;
53
+ }
54
+
55
+ // One end of a clock-time range. The second is optional and may be absent
56
+ // (top-of-hour windows) or a folded clock second.
57
+ type ClockEnd = {hour: number; minute: number; second?: number | null};
58
+
59
+ // A name token: a cron name or numeric string from a segment, or a numeric
60
+ // fire that `flattenSteps` expands a step into.
61
+ type NameToken = string | number;
62
+
63
+ // A flattened name segment. `flattenSteps` turns step segments into single
64
+ // segments whose `value` is a numeric fire, so a single's value here may be
65
+ // a number as well as a `Segment`'s string token; ranges keep their bounds.
66
+ type NameSegment =
67
+ | {kind: 'single'; value: NameToken}
68
+ | {kind: 'range'; bounds: [string, string]};
69
+
70
+ // The range and single arms of a flattened name segment.
71
+ type RangeNameSegment = Extract<NameSegment, {kind: 'range'}>;
72
+ type SingleNameSegment = Extract<NameSegment, {kind: 'single'}>;
73
+
74
+
75
+ // Portuguese number names for the integers zero through ten.
76
+ const numeros = [
77
+ 'zero',
78
+ 'um',
79
+ 'dois',
80
+ 'três',
81
+ 'quatro',
82
+ 'cinco',
83
+ 'seis',
84
+ 'sete',
85
+ 'oito',
86
+ 'nove',
87
+ 'dez'
88
+ ];
89
+
90
+ // Portuguese month names (lowercase, per VOLP).
91
+ const monthNames = [
92
+ null,
93
+ 'janeiro',
94
+ 'fevereiro',
95
+ 'março',
96
+ 'abril',
97
+ 'maio',
98
+ 'junho',
99
+ 'julho',
100
+ 'agosto',
101
+ 'setembro',
102
+ 'outubro',
103
+ 'novembro',
104
+ 'dezembro'
105
+ ];
106
+
107
+ // Portuguese weekday names (lowercase, per VOLP). Weekdays Mon-Fri carry the
108
+ // "-feira" element; sábado and domingo do not. The bare stem ("segunda") is
109
+ // kept separate so lists can suffix "-feira" on the last -feira day only and
110
+ // ranges on the last term only (notes.md §"Weekday recurrence").
111
+ const weekdayNames = [
112
+ 'domingo',
113
+ 'segunda-feira',
114
+ 'terça-feira',
115
+ 'quarta-feira',
116
+ 'quinta-feira',
117
+ 'sexta-feira',
118
+ 'sábado'
119
+ ];
120
+
121
+ // The bare weekday stems (no "-feira"), for list/range suffix-ellipsis.
122
+ const weekdayStems = [
123
+ 'domingo',
124
+ 'segunda',
125
+ 'terça',
126
+ 'quarta',
127
+ 'quinta',
128
+ 'sexta',
129
+ 'sábado'
130
+ ];
131
+
132
+ // Whether a weekday (by canonical number) is feminine. The -feira days are
133
+ // feminine ("a segunda-feira" → "às segundas-feiras"); sábado and domingo are
134
+ // masculine ("o domingo" → "aos domingos"). Drives ordinal and article gender.
135
+ function weekdayFeminine(number: number): boolean {
136
+ return number !== 0 && number !== 6;
137
+ }
138
+
139
+ // Gendered ordinals for Quartz `#` weekday occurrences (1-5). The renderer
140
+ // selects by the weekday's gender ("a primeira segunda-feira", "o último
141
+ // domingo"); es used invariant primer/último, pt must agree.
142
+ const nthWeekdayMasculine =
143
+ [null, 'primeiro', 'segundo', 'terceiro', 'quarto', 'quinto'];
144
+ const nthWeekdayFeminine =
145
+ [null, 'primeira', 'segunda', 'terceira', 'quarta', 'quinta'];
146
+
147
+ // --- Contractions (the principal es->pt divergence). ---
148
+ //
149
+ // Portuguese fuses a preposition with the following article wherever es emitted
150
+ // a bare preposition + article. These helpers form the contraction from a
151
+ // phrase that begins with a bare article ("a 01:00", "as 09:00", "o dia",
152
+ // "as segundas-feiras"): de+a/o/as/os -> da/do/das/dos; em+... -> na/no/nas;
153
+ // a+a/as -> à/às, a+o/os -> ao/aos. It is gender/number-driven formation, not a
154
+ // lexical substitution. Clock and weekday phrases carry their bare article so
155
+ // these fuse uniformly.
156
+
157
+ // The genitive noon/midnight words carry an implicit gendered article: meio-dia
158
+ // is masculine (o → ao/do), meia-noite feminine (a → à/da). These are words,
159
+ // not bare-article phrases, so the contraction helpers special-case them.
160
+ function noonMidnightArticle(phrase: string): 'o' | 'a' | null {
161
+ if (phrase === 'meio-dia') {
162
+ return 'o';
163
+ }
164
+
165
+ if (phrase === 'meia-noite') {
166
+ return 'a';
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ // The contraction of `a` + a bare-article phrase: a+a=à, a+as=às, a+o=ao,
173
+ // a+os=aos. Noon/midnight word forms carry an implicit gendered article
174
+ // (ao meio-dia, à meia-noite); any other word form falls through with "a".
175
+ function withA(phrase: string): string {
176
+ const word = noonMidnightArticle(phrase);
177
+
178
+ if (word) {
179
+ return (word === 'o' ? 'ao ' : 'à ') + phrase;
180
+ }
181
+
182
+ if (phrase.startsWith('as ')) {
183
+ return 'às ' + phrase.slice(3);
184
+ }
185
+
186
+ if (phrase.startsWith('a ')) {
187
+ return 'à ' + phrase.slice(2);
188
+ }
189
+
190
+ if (phrase.startsWith('os ')) {
191
+ return 'aos ' + phrase.slice(3);
192
+ }
193
+
194
+ if (phrase.startsWith('o ')) {
195
+ return 'ao ' + phrase.slice(2);
196
+ }
197
+
198
+ return 'a ' + phrase;
199
+ }
200
+
201
+ // The contraction of `de` + a bare-article phrase: de+a=da, de+as=das,
202
+ // de+o=do, de+os=dos. Noon/midnight take their gendered article (do meio-dia,
203
+ // da meia-noite).
204
+ function withDe(phrase: string): string {
205
+ const word = noonMidnightArticle(phrase);
206
+
207
+ if (word) {
208
+ return (word === 'o' ? 'do ' : 'da ') + phrase;
209
+ }
210
+
211
+ if (phrase.startsWith('as ')) {
212
+ return 'das ' + phrase.slice(3);
213
+ }
214
+
215
+ if (phrase.startsWith('a ')) {
216
+ return 'da ' + phrase.slice(2);
217
+ }
218
+
219
+ if (phrase.startsWith('os ')) {
220
+ return 'dos ' + phrase.slice(3);
221
+ }
222
+
223
+ if (phrase.startsWith('o ')) {
224
+ return 'do ' + phrase.slice(2);
225
+ }
226
+
227
+ return 'de ' + phrase;
228
+ }
229
+
230
+ // Whether a phrase begins with a bare article the contraction helpers fuse
231
+ // (or a noon/midnight word, which carries an implicit one). A phrase that does
232
+ // not — the Quartz "5 dias antes do último dia do mês" offset form — takes no
233
+ // preposition in the leading/arm position, so the caller leaves it bare.
234
+ function hasLeadingArticle(phrase: string): boolean {
235
+ return noonMidnightArticle(phrase) !== null ||
236
+ phrase.startsWith('a ') || phrase.startsWith('as ') ||
237
+ phrase.startsWith('o ') || phrase.startsWith('os ');
238
+ }
239
+
240
+ // The contraction of `em` + a bare-article phrase: em+a=na, em+as=nas,
241
+ // em+o=no, em+os=nos.
242
+ function withEm(phrase: string): string {
243
+ if (phrase.startsWith('as ')) {
244
+ return 'nas ' + phrase.slice(3);
245
+ }
246
+
247
+ if (phrase.startsWith('a ')) {
248
+ return 'na ' + phrase.slice(2);
249
+ }
250
+
251
+ if (phrase.startsWith('os ')) {
252
+ return 'nos ' + phrase.slice(3);
253
+ }
254
+
255
+ if (phrase.startsWith('o ')) {
256
+ return 'no ' + phrase.slice(2);
257
+ }
258
+
259
+ return 'em ' + phrase;
260
+ }
261
+
262
+ // Normalize raw user options.
263
+ function normalizeOptions(options?: Cronli5Options): Opts {
264
+ options = options || {};
265
+ const style = resolveDialect(options.dialect);
266
+
267
+ return {
268
+ // The clock default comes from the dialect (24-hour for pt-BR); an explicit
269
+ // `{ampm}` option overrides it.
270
+ ampm: typeof options.ampm === 'boolean' ? options.ampm : style.ampm,
271
+ lenient: !!options.lenient,
272
+ quartz: !!options.quartz,
273
+ seconds: !!options.seconds,
274
+ short: !!options.short,
275
+ style,
276
+ years: !!options.years
277
+ };
278
+ }
279
+
280
+ // Render an analyzed cron pattern (the Schedule) as Portuguese.
281
+ function describe(schedule: Schedule, opts: Opts): string {
282
+ return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
283
+ }
284
+
285
+ // Render one plan node. `composeSeconds` recurses with its `rest` plan.
286
+ // When BOTH date and weekday are restricted (a date-OR-weekday union), the
287
+ // result is wrapped in the unified `[month] [time], seja <DOM> ou <DOW>`
288
+ // frame regardless of arm shapes or month type.
289
+ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
290
+ // Each renderer narrows `plan` to its own `kind`; the dispatch table is
291
+ // keyed by that discriminant, so the union-to-specific match is sound but
292
+ // not expressible without a cast.
293
+ const phrase = (renderers[plan.kind] as Renderer)(schedule, plan, opts);
294
+
295
+ if (!isDateWeekdayUnion(schedule)) {
296
+ return phrase;
297
+ }
298
+
299
+ // The time/frequency phrase arrives from the renderer with no day qualifier
300
+ // (leadingQualifier and trailingQualifier both return '' for union patterns).
301
+ // Front the shared month (possibly with a trailing comma for enumerations),
302
+ // then append the union correlative last.
303
+ const lead = unionMonthLeadFull(schedule);
304
+
305
+ return (lead ? lead + ' ' : '') + phrase + unionSejaSuffix(schedule, opts);
306
+ }
307
+
308
+ // --- Seconds renderers. ---
309
+
310
+ function renderEverySecond(
311
+ schedule: Schedule,
312
+ plan: Extract<PlanNode, {kind: 'everySecond'}>,
313
+ opts: Opts
314
+ ): string {
315
+ return 'a cada segundo' + trailingQualifier(schedule, opts);
316
+ }
317
+
318
+ function renderStandaloneSeconds(
319
+ schedule: Schedule,
320
+ plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
321
+ opts: Opts
322
+ ): string {
323
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
324
+ }
325
+
326
+ function renderSecondPastMinute(
327
+ schedule: Schedule,
328
+ plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
329
+ opts: Opts
330
+ ): string {
331
+ return 'no segundo ' + schedule.pattern.second + ' de cada minuto' +
332
+ trailingQualifier(schedule, opts);
333
+ }
334
+
335
+ // A meaningful second combined with a single specific minute (and an open
336
+ // hour): a single second folds into the minute anchor; a list, range, or
337
+ // step leads with its own clause.
338
+ function renderSecondsWithinMinute(
339
+ schedule: Schedule,
340
+ plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
341
+ opts: Opts
342
+ ): string {
343
+ const minuteField = schedule.pattern.minute;
344
+
345
+ if (plan.singleSecond) {
346
+ return 'no minuto ' + minuteField + ' e no segundo ' +
347
+ schedule.pattern.second + ' de cada hora' +
348
+ trailingQualifier(schedule, opts);
349
+ }
350
+
351
+ return secondsLeadClause(schedule, opts) + ', no minuto ' + minuteField +
352
+ ' de cada hora' + trailingQualifier(schedule, opts);
353
+ }
354
+
355
+ // A seconds list nested into one or more fixed clock times ("..., nos
356
+ // segundos 5 e 30 das 09:00 e 17:00"). An offset/uneven second step the core
357
+ // enumerated to this list reads as a stride cadence; otherwise the fires are
358
+ // listed. The clock time follows with the genitive "de" (fused to "das"), so
359
+ // the stride drops its "de cada minuto" anchor.
360
+ function secondsListAtClock(
361
+ schedule: Schedule,
362
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
363
+ opts: Opts
364
+ ): string {
365
+ const clockPhrases = rest.times.map(function clock(time) {
366
+ return atTime(timePhrase(time.hour, time.minute, null, opts));
367
+ });
368
+ const grouped = groupClockTimesByArticle(clockPhrases);
369
+ // Reframe the grouped "a(s)/à(s)" result to the genitive "de" form so the
370
+ // caller produces "das 09:00 e 17:00".
371
+ const clockList = degenitive(grouped);
372
+ const stride =
373
+ strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', '', opts);
374
+ const secondsPhrase = stride ?? 'nos segundos ' +
375
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
376
+ const dayFrame = trailingQualifier(schedule, opts);
377
+
378
+ return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
379
+ secondsPhrase + ' ' + clockList;
380
+ }
381
+
382
+ // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
383
+ // would cross-multiply an hour stride under a single pinned minute, or null
384
+ // when that does not apply (a non-clock rest, a multi-valued minute, or an
385
+ // hour that is not a stride).
386
+ function composeHourCadence(
387
+ schedule: Schedule,
388
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
389
+ opts: Opts
390
+ ): string | null {
391
+ const clockRest = plan.rest.kind === 'clockTimes' ||
392
+ plan.rest.kind === 'compactClockTimes';
393
+
394
+ if (!clockRest || schedule.shapes.minute !== 'single') {
395
+ return null;
396
+ }
397
+
398
+ const minute = +schedule.pattern.minute;
399
+
400
+ return hourCadence(schedule, minute, opts) ??
401
+ hourRangeCadence(schedule, minute, opts);
402
+ }
403
+
404
+ // A wildcard or stepped second with a fixed minute across one or more specific
405
+ // hours: the seconds confine to the clock time(s), each minute named.
406
+ function isPinnedMinuteSeconds(
407
+ schedule: Schedule,
408
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
409
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
410
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
411
+ return plan.rest.kind === 'clockTimes' &&
412
+ (schedule.shapes.second === 'wildcard' ||
413
+ schedule.shapes.second === 'step');
414
+ }
415
+
416
+ function renderComposeSeconds(
417
+ schedule: Schedule,
418
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
419
+ opts: Opts
420
+ ): string {
421
+ // An hour step (or arithmetic-progression hour list) under a single pinned
422
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
423
+ // then the hour cadence ("no segundo 30 de cada hora, a cada duas horas").
424
+ // The clock-time rest would otherwise cross-multiply the hours.
425
+ const hourCad = composeHourCadence(schedule, plan, opts);
426
+
427
+ if (hourCad !== null) {
428
+ return hourCad;
429
+ }
430
+
431
+ // A wildcard or stepped second with the minute pinned to a single value
432
+ // across one or more specific hours: the seconds confine to the clock time.
433
+ if (isPinnedMinuteSeconds(schedule, plan)) {
434
+ return pinnedMinuteSeconds(schedule, plan.rest, opts);
435
+ }
436
+
437
+ // Seconds list + fixed clock time: nest the seconds into the clock time(s)
438
+ // with genitive "das HH:MM" instead of "de cada minuto"; the minute is
439
+ // fixed so "de cada minuto" is misleading. Single seconds already fold into
440
+ // the time in the clockTimes renderer; step seconds keep their own clause.
441
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.second === 'list') {
442
+ return secondsListAtClock(schedule, plan.rest, opts);
443
+ }
444
+
445
+ // Second-step + fixed minute + hour range + weekday: anchor the cadence to
446
+ // the minute after the weekday + hour-range frame.
447
+ if (plan.rest.kind === 'hourRange' && schedule.shapes.second === 'step' &&
448
+ schedule.pattern.weekday !== '*') {
449
+ const restNode = plan.rest;
450
+ const window = hourWindow(boundedWindow(restNode), opts);
451
+ const dayFrame = weekdayQualifier(schedule) + monthScope(schedule);
452
+ const cadence = 'a cada ' +
453
+ numero(stepSegment(schedule, 'second').interval, opts) +
454
+ ' segundos do minuto ' + schedule.pattern.minute;
455
+
456
+ return dayFrame + ', ' + window + ', ' + cadence;
457
+ }
458
+
459
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
460
+ // cadences that read as contradictory ("a cada segundo, a cada dois
461
+ // minutos"). Bind them with the genitive "de" ("a cada segundo de cada dois
462
+ // minutos"), mirroring English. The rest renders "a cada dois minutos"; the
463
+ // genitive "de" absorbs its leading "a", giving "de cada dois minutos".
464
+ // Other strides, a restricted hour, and an hour cadence keep the juxtaposed
465
+ // form.
466
+ if (isEveryOtherMinuteSeconds(schedule, plan)) {
467
+ const rest = render(schedule, plan.rest, opts).replace(/^a /u, '');
468
+
469
+ return secondsLeadClause(schedule, opts) + ' de ' + rest;
470
+ }
471
+
472
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
473
+ // leading clause, so the composer must not prepend a second lead that would
474
+ // double it. A wildcard or stepped second is not folded there (no
475
+ // clockSecond), so it still leads its own clause here.
476
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
477
+ schedule.analyses.clockSecond;
478
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
479
+
480
+ return lead + render(schedule, plan.rest, opts);
481
+ }
482
+
483
+ // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
484
+ // cadences read as contradictory side by side, so they bind into one.
485
+ function isEveryOtherMinuteSeconds(
486
+ schedule: Schedule,
487
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
488
+ ): boolean {
489
+ if (plan.rest.kind !== 'minuteFrequency' ||
490
+ schedule.shapes.second !== 'wildcard' ||
491
+ schedule.shapes.hour !== 'wildcard') {
492
+ return false;
493
+ }
494
+
495
+ const minuteStep = stepSegment(schedule, 'minute');
496
+
497
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
498
+ }
499
+
500
+ // A wildcard or stepped second under a single pinned minute and specific
501
+ // hour(s). The clock-time rest folds the minute into the hour, and on the
502
+ // 12-hour clock a pinned minute-0 drops the :00 entirely ("às 9 da manhã") —
503
+ // and even "às 9" reads aloud as the whole hour, hiding the one-minute
504
+ // confinement (60 fires in :00, not 3,600 across the hour). Minute 0 is the
505
+ // one-minute window at the top of each named hour: a duration frame ("durante
506
+ // um minuto às 9") states the confinement outright, with the hour as a bare
507
+ // hour so it cannot be heard as the whole hour. A non-zero pinned minute is an
508
+ // unambiguous clock time, so the genitive "das 09:05" form reads it as the
509
+ // minute, never the hour.
510
+ function pinnedMinuteSeconds(
511
+ schedule: Schedule,
512
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
513
+ opts: Opts
514
+ ): string {
515
+ // The day qualifier trails after a comma here, so the weekday reads the
516
+ // plural recurrence ("às segundas-feiras"), never the leading "toda X" head.
517
+ const dayTrail = trailingDayClause(schedule, opts);
518
+ const trail = dayTrail ? ', ' + dayTrail : '';
519
+
520
+ // The "durante um minuto às 9" duration form drops the clock minute, so it
521
+ // is correct only when the minute is a SINGLE 0 — every clock time at :00. A
522
+ // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
523
+ // minute, never collapse to the bare hour, so it takes the explicit clock
524
+ // list.
525
+ if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
526
+ return secondsLeadClause(schedule, opts) + ' durante um minuto ' +
527
+ durationHourList(rest.times, opts) + trail;
528
+ }
529
+
530
+ return secondsLeadClause(schedule, opts) + ' ' +
531
+ explicitClockList(rest.times, opts) + trail;
532
+ }
533
+
534
+ // The leading clause describing a second field relative to the minute.
535
+ function secondsLeadClause(schedule: Schedule, opts: Opts): string {
536
+ return secondsClause(schedule, 'minuto', opts);
537
+ }
538
+
539
+ // The second clause counted against an arbitrary anchor. The anchor is
540
+ // "minuto" in the standalone seconds path; the hour-cadence path folds a
541
+ // pinned minute 0 into the hour and counts the second "de cada hora" instead
542
+ // ("no segundo 30 de cada hora"), so the minute-0 confinement is stated,
543
+ // not dropped.
544
+ function secondsClause(schedule: Schedule, anchor: string, opts: Opts): string {
545
+ const secondField = schedule.pattern.second;
546
+ const shape = schedule.shapes.second;
547
+
548
+ if (secondField === '*') {
549
+ return 'a cada segundo';
550
+ }
551
+
552
+ if (shape === 'step') {
553
+ return stepCycle60(stepSegment(schedule, 'second'), 'segundo',
554
+ anchor, opts);
555
+ }
556
+
557
+ if (shape === 'range') {
558
+ const bounds = secondField.split('-');
559
+
560
+ return 'a cada segundo do ' + bounds[0] + ' ao ' + bounds[1] +
561
+ ' de cada ' + anchor;
562
+ }
563
+
564
+ if (shape === 'single') {
565
+ return 'no segundo ' + secondField + ' de cada ' + anchor;
566
+ }
567
+
568
+ return strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', anchor,
569
+ opts) ?? 'nos segundos ' +
570
+ joinList(segmentWords(segmentsOf(schedule, 'second'))) +
571
+ ' de cada ' + anchor;
572
+ }
573
+
574
+ // --- Minute renderers. ---
575
+
576
+ function renderEveryMinute(
577
+ schedule: Schedule,
578
+ plan: Extract<PlanNode, {kind: 'everyMinute'}>,
579
+ opts: Opts
580
+ ): string {
581
+ return 'a cada minuto' + trailingQualifier(schedule, opts);
582
+ }
583
+
584
+ function renderSingleMinute(
585
+ schedule: Schedule,
586
+ plan: Extract<PlanNode, {kind: 'singleMinute'}>,
587
+ opts: Opts
588
+ ): string {
589
+ return 'no minuto ' + schedule.pattern.minute + ' de cada hora' +
590
+ trailingQualifier(schedule, opts);
591
+ }
592
+
593
+ function renderRangeOfMinutes(
594
+ schedule: Schedule,
595
+ plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
596
+ opts: Opts
597
+ ): string {
598
+ return minuteRangeLead(schedule.pattern.minute) + ' de cada hora' +
599
+ trailingQualifier(schedule, opts);
600
+ }
601
+
602
+ function renderMultipleMinutes(
603
+ schedule: Schedule,
604
+ plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
605
+ opts: Opts
606
+ ): string {
607
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
608
+ }
609
+
610
+ // "nos minutos 5, 10 e 30 de cada hora". An offset/uneven step the core
611
+ // enumerated to this list reads as a stride cadence when the fires form a
612
+ // long-enough progression.
613
+ function minutesList(schedule: Schedule, opts: Opts): string {
614
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minuto', 'hora',
615
+ opts) ?? 'nos minutos ' +
616
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
617
+ }
618
+
619
+ // "a cada minuto do 0 ao 30". The standalone renderer adds "de cada hora";
620
+ // when an hour qualifier follows ("..., às 09:00", "..., a cada duas horas")
621
+ // it would contradict, so it is not baked in here.
622
+ function minuteRangeLead(minuteField: string): string {
623
+ const bounds = minuteField.split('-');
624
+
625
+ return 'a cada minuto do ' + bounds[0] + ' ao ' + bounds[1];
626
+ }
627
+
628
+ // Whether the hour field is a single step, which pt renders as a confinement
629
+ // phrase rather than a window list.
630
+ function singleHourStep(segments: Segment[] | null): boolean {
631
+ return segments !== null && segments.length === 1 &&
632
+ segments[0].kind === 'step';
633
+ }
634
+
635
+ // A single hour step as a confinement. A stride of two over the whole day
636
+ // reads idiomatically as the even ("as horas pares") or odd ("ímpares")
637
+ // hours; any other step names its active hours, which pins the schedule
638
+ // precisely (ordinal/colloquial forms would be imprecise here).
639
+ function stepHourSpan(segment: StepSegment, opts: Opts): string {
640
+ const bounded = segment.startToken.indexOf('-') !== -1;
641
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
642
+
643
+ if (segment.interval === 2 && !bounded && start <= 1) {
644
+ return start === 0 ?
645
+ 'durante as horas pares' :
646
+ 'durante as horas ímpares';
647
+ }
648
+
649
+ return 'durante as horas ' + hourSpanList(segment.fires, opts);
650
+ }
651
+
652
+ // The active hours of a confined cadence, dialect-aware. The 24-hour clock
653
+ // shares one article over bare numbers ("das 14, 18, 20 e 22"). The 12-hour
654
+ // clock groups the hours by day period, naming each period once ("das 9 e 11
655
+ // da manhã e da 1, das 3 e das 5 da tarde"); noon and midnight stand alone as
656
+ // "do meio-dia" / "da meia-noite".
657
+ function hourSpanList(fires: number[], opts: Opts): string {
658
+ if (!opts.ampm) {
659
+ return 'das ' + joinList(fires.map(String));
660
+ }
661
+
662
+ return joinList(hourPeriodGroups(fires, opts));
663
+ }
664
+
665
+ // The day period a 12-hour clock appends to an hour: the AM/PM mark for the
666
+ // 'english' meridiem (no shipped dialect), otherwise the day-period descriptor
667
+ // ("da manhã").
668
+ function hourPeriod(hour: number, opts: Opts): string {
669
+ return opts.style.meridiem === 'english' ?
670
+ meridiemMark(hour) :
671
+ dayPeriod(hour, opts);
672
+ }
673
+
674
+ // Fire hours as per-period phrases: consecutive hours sharing a day period
675
+ // fold under it once ("das 9 e 11 da manhã"); noon and midnight are their own
676
+ // markers ("do meio-dia", "da meia-noite").
677
+ function hourPeriodGroups(fires: number[], opts: Opts): string[] {
678
+ const groups: {hours: number[]; period: string}[] = [];
679
+
680
+ fires.forEach(function place(hour): void {
681
+ const period = hour === 0 || hour === 12 ? '' : hourPeriod(hour, opts);
682
+ const last = groups[groups.length - 1];
683
+
684
+ if (period !== '' && last && last.period === period) {
685
+ last.hours.push(hour);
686
+ }
687
+ else {
688
+ groups.push({hours: [hour], period});
689
+ }
690
+ });
691
+
692
+ return groups.map(function phrase(group): string {
693
+ if (group.period === '') {
694
+ return fromTime(timePhrase(group.hours[0], 0, null, opts));
695
+ }
696
+
697
+ return spanHours(group.hours) + ' ' + group.period;
698
+ });
699
+ }
700
+
701
+ // The hours of one period in the genitive "de" form: "das 9 e 11" when all
702
+ // take the plural article (one shared "das" head), "da 1, das 4 e das 7" when
703
+ // a one-o'clock mixes in — each value contracts its own preposition (de+a=da,
704
+ // de+as=das), since "das" cannot govern the singular "1".
705
+ function spanHours(hours: number[]): string {
706
+ const display = hours.map(function twelve(hour): number {
707
+ return hour % 12 || 12;
708
+ });
709
+
710
+ if (display.indexOf(1) === -1) {
711
+ return 'das ' + joinList(display.map(String));
712
+ }
713
+
714
+ return joinList(display.map(function article(hour): string {
715
+ return withDe((hour === 1 ? 'a ' : 'as ') + hour);
716
+ }));
717
+ }
718
+
719
+ // A repeating minute step, qualified by the active hour window(s).
720
+ function renderMinuteFrequency(
721
+ schedule: Schedule,
722
+ plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
723
+ opts: Opts
724
+ ): string {
725
+ let phrase = stepCycle60(stepSegment(schedule, 'minute'), 'minuto',
726
+ 'hora', opts);
727
+
728
+ if (plan.hours.kind === 'during') {
729
+ // A uneven hour stride confines the minute cadence to its own bounded hour
730
+ // cadence ("a cada 15 minutos, a cada cinco horas das 00:00 às 20:00").
731
+ const cadence = unevenHourCadence(schedule, opts);
732
+
733
+ if (cadence) {
734
+ phrase += ', ' + cadence;
735
+ }
736
+ else {
737
+ // An offset step (e.g. 1/2) arrives here; a single step reads as a
738
+ // confinement, not the verbose window list.
739
+ phrase += singleHourStep(schedule.analyses.segments.hour) ?
740
+ ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts) :
741
+ ' ' + hourSpanFromTimes(schedule, plan.hours.times, opts);
742
+ }
743
+ }
744
+ else if (plan.hours.kind === 'window') {
745
+ phrase += ' ' + hourWindow(plan.hours, opts);
746
+ }
747
+ else if (plan.hours.kind === 'step') {
748
+ // A clean stride is a confinement ("as horas pares", or the active-hour
749
+ // list), never a juxtaposed cadence ("a cada duas horas").
750
+ phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
751
+ }
752
+
753
+ return phrase + trailingQualifier(schedule, opts);
754
+ }
755
+
756
+ // "a cada minuto das 9:00 às 9:29 da manhã". A wildcard minute is the whole
757
+ // hour, so it reads as that hour itself ("a cada minuto da hora das 09:00")
758
+ // rather than a synthesized "das HH:00 às HH:59" range the source never stated;
759
+ // a plain range is a real window and keeps "das … às …".
760
+ function renderMinuteSpanInHour(
761
+ schedule: Schedule,
762
+ plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
763
+ opts: Opts
764
+ ): string {
765
+ if (schedule.pattern.minute === '*') {
766
+ return 'a cada minuto da hora ' +
767
+ fromTime(timePhrase(plan.hour, 0, null, opts)) +
768
+ trailingQualifier(schedule, opts);
769
+ }
770
+
771
+ return 'a cada minuto ' +
772
+ timeRange({hour: plan.hour, minute: plan.span[0]},
773
+ {hour: plan.hour, minute: plan.span[1]}, opts) +
774
+ trailingQualifier(schedule, opts);
775
+ }
776
+
777
+ // A minute window under discrete hours. Portuguese re-plans the wildcard form:
778
+ // rather than "during the X hours", each hour reads as its own window ("das
779
+ // 9:00 às 9:59").
780
+ function renderMinutesAcrossHours(
781
+ schedule: Schedule,
782
+ plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
783
+ opts: Opts
784
+ ): string {
785
+ // A uneven hour stride reads as a cadence, not a wall of hour columns: the
786
+ // minute lead, then "a cada N horas das X às Y".
787
+ const cadence = unevenHourCadence(schedule, opts);
788
+
789
+ if (plan.form === 'wildcard') {
790
+ if (cadence !== null) {
791
+ return 'a cada minuto, ' + cadence + trailingQualifier(schedule, opts);
792
+ }
793
+
794
+ if (singleHourStep(schedule.analyses.segments.hour)) {
795
+ return 'a cada minuto, ' +
796
+ stepHourSpan(stepSegment(schedule, 'hour'), opts) +
797
+ trailingQualifier(schedule, opts);
798
+ }
799
+
800
+ return 'a cada minuto ' + hourSpanFromTimes(schedule, plan.times, opts) +
801
+ trailingQualifier(schedule, opts);
802
+ }
803
+
804
+ const lead = plan.form === 'range' ?
805
+ minuteRangeLead(schedule.pattern.minute) :
806
+ minutesList(schedule, opts);
807
+
808
+ if (cadence !== null) {
809
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
810
+ }
811
+
812
+ return lead + ', ' + atHourTimes(schedule, plan.times, opts) +
813
+ trailingQualifier(schedule, opts);
814
+ }
815
+
816
+ function renderMinuteSpanAcrossHourStep(
817
+ schedule: Schedule,
818
+ plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
819
+ opts: Opts
820
+ ): string {
821
+ const segment = stepSegment(schedule, 'hour');
822
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
823
+ // offset-clean step keeps its confinement / per-step phrasing.
824
+ const cadence = unevenHourCadence(schedule, opts);
825
+
826
+ // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
827
+ // or uneven step routes through minutesAcrossHours instead) and is confined.
828
+ if (plan.form === 'wildcard') {
829
+ return 'a cada minuto, ' + stepHourSpan(segment, opts) +
830
+ trailingQualifier(schedule, opts);
831
+ }
832
+
833
+ // A minute list keeps the same cadence clause as the range; only its lead
834
+ // differs ("nos minutos 5 e 30 de cada hora" vs "a cada minuto do 0 ao 30").
835
+ const lead = plan.form === 'list' ?
836
+ minutesList(schedule, opts) :
837
+ minuteRangeLead(schedule.pattern.minute);
838
+
839
+ return lead + ', ' +
840
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
841
+ }
842
+
843
+ // --- Hour renderers. ---
844
+
845
+ function renderEveryHour(
846
+ schedule: Schedule,
847
+ plan: Extract<PlanNode, {kind: 'everyHour'}>,
848
+ opts: Opts
849
+ ): string {
850
+ return 'a cada hora' + trailingQualifier(schedule, opts);
851
+ }
852
+
853
+ function renderHourRange(
854
+ schedule: Schedule,
855
+ plan: Extract<PlanNode, {kind: 'hourRange'}>,
856
+ opts: Opts
857
+ ): string {
858
+ const window = hourWindow(boundedWindow(plan), opts);
859
+
860
+ if (plan.minuteForm === 'wildcard') {
861
+ return 'a cada minuto ' + window + trailingQualifier(schedule, opts);
862
+ }
863
+
864
+ if (plan.minuteForm === 'range') {
865
+ return minuteRangeLead(schedule.pattern.minute) + ', ' + window +
866
+ trailingQualifier(schedule, opts);
867
+ }
868
+
869
+ // On the hour the window joins directly ("a cada hora das 9:00 às 17:00"); a
870
+ // discrete minute anchors its own clause first.
871
+ if (schedule.pattern.minute === '0') {
872
+ return 'a cada hora ' + window + trailingQualifier(schedule, opts);
873
+ }
874
+
875
+ const lead = schedule.shapes.minute === 'single' ?
876
+ 'no minuto ' + schedule.pattern.minute + ' de cada hora' :
877
+ minutesList(schedule, opts);
878
+
879
+ return lead + ', ' + window + trailingQualifier(schedule, opts);
880
+ }
881
+
882
+ function renderHourStep(
883
+ schedule: Schedule,
884
+ plan: Extract<PlanNode, {kind: 'hourStep'}>,
885
+ opts: Opts
886
+ ): string {
887
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence ("a
888
+ // cada duas horas das 09:00 às 17:00"); an offset-clean step keeps its bare
889
+ // or "a partir de" cadence.
890
+ const cadence = unevenHourCadence(schedule, opts);
891
+
892
+ if (cadence !== null) {
893
+ return cadence + trailingQualifier(schedule, opts);
894
+ }
895
+
896
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
897
+ trailingQualifier(schedule, opts);
898
+ }
899
+
900
+ // The hour-range plan as a window. The close lands on the top of the final
901
+ // hour (minute 0) unless the minute genuinely runs to the end of that hour —
902
+ // i.e. a wildcard minute, which fills every minute and states no separate
903
+ // clause. A pinned/listed/ranged minute is named in its own lead clause, so
904
+ // folding it into the close too would read as a span ("às 17:05") that
905
+ // contradicts the minute clause; the window stays bare ("às 17:00").
906
+ function boundedWindow(
907
+ plan: Extract<PlanNode, {kind: 'hourRange'}>
908
+ ): {from: number; to: number; last: number} {
909
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
910
+
911
+ return {from: plan.from, last, to: plan.to};
912
+ }
913
+
914
+ // "das 9:00 às 17:45": a window from the top of the first hour to the minute
915
+ // field's last fire within the final hour.
916
+ function hourWindow(
917
+ window: {from: number; to: number; last: number},
918
+ opts: Opts
919
+ ): string {
920
+ return timeRange({hour: window.from, minute: 0},
921
+ {hour: window.to, minute: window.last}, opts);
922
+ }
923
+
924
+ // Whether BOTH the date and weekday fields are restricted (not '*'): cron
925
+ // fires when either condition matches, making this a date-OR-weekday union.
926
+ function isDateWeekdayUnion(schedule: Schedule): boolean {
927
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
928
+ }
929
+
930
+ // The month lead for the unified union frame, with a trailing comma appended
931
+ // when the lead is a heavy enumeration (≥2 non-range months).
932
+ // Single month → `em janeiro`; range → `de janeiro a março`;
933
+ // step/enumeration (≥2 flattened singles) → `em janeiro, …, e novembro,`.
934
+ // Wildcard month → '' (omit; frame starts with the time).
935
+ function unionMonthLeadFull(schedule: Schedule): string {
936
+ if (schedule.pattern.month === '*') {
937
+ return '';
938
+ }
939
+
940
+ const lead = monthPhrase(schedule, monthRanged(schedule) ? 'de ' : 'em ');
941
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
942
+ const isEnumeration = !monthRanged(schedule) && segments.length >= 2;
943
+
944
+ return isEnumeration ? lead + ',' : lead;
945
+ }
946
+
947
+ // The DOM arm for the union frame — month-less, driven by the date shape.
948
+ // Quartz and open-step forms are self-contained; ranges use `do dia N ao dia M
949
+ // do mês`; a single date reads `no dia N` under a restricted month (month is in
950
+ // the lead) or `no dia N de cada mês` under a wildcard month. The 1st is the
951
+ // ordinal "1º".
952
+ function domArm(schedule: Schedule, opts: Opts): string {
953
+ const date = schedule.pattern.date;
954
+ const quartz = quartzDatePhrase(date);
955
+
956
+ if (quartz) {
957
+ return hasLeadingArticle(quartz) ? withEm(quartz) : quartz;
958
+ }
959
+
960
+ // In the union the `*/2` day-of-month is a parity predicate over the days of
961
+ // the month ("um dia ímpar do mês" = 1, 3, …, 31, resetting each month), not
962
+ // the durative "a cada dois dias do mês" the standalone form uses. A bare
963
+ // "a cada dois dias" would mis-imply a continuous every-other-day cadence
964
+ // with no monthly anchor, so the reader could not reconstruct the odd days.
965
+ const parity = parityDayPredicate(date);
966
+
967
+ if (parity) {
968
+ return 'em ' + parity;
969
+ }
970
+
971
+ if (isOpenStep(date)) {
972
+ // The open-step date arm is the bare cadence "a cada N dias do mês" (the
973
+ // es donor returns it bare too). Its leading "a cada" is the durative
974
+ // "every", not an article, so it takes no preposition — wrapping it in
975
+ // withEm would mis-fuse it to "na cada".
976
+ return stepDates(date, opts);
977
+ }
978
+
979
+ const segments = segmentsOf(schedule, 'date');
980
+
981
+ if (segments.length === 1 && segments[0].kind === 'range') {
982
+ return 'do dia ' + dayOrdinal(segments[0].bounds[0]) + ' ao dia ' +
983
+ segments[0].bounds[1] + ' do mês';
984
+ }
985
+
986
+ if (segments.length === 1 && segments[0].kind === 'single') {
987
+ return schedule.pattern.month === '*' ?
988
+ 'no dia ' + dayOrdinal(segments[0].value) + ' de cada mês' :
989
+ 'no dia ' + dayOrdinal(segments[0].value);
990
+ }
991
+
992
+ return 'nos dias ' + joinList(dateWords(segments)) + ' do mês';
993
+ }
994
+
995
+ // The DOW arm for the union frame — month-less, driven by the weekday shape.
996
+ // Quartz forms are self-contained; a single weekday reads the Brazilian
997
+ // recurrence `às [weekday]s-feiras` / `aos domingos`; all other forms use the
998
+ // same phrasing as the standalone weekday qualifier (range → `em qualquer dia
999
+ // de segunda a sexta-feira`; list/step → `às segundas, …`).
1000
+ function dowArm(schedule: Schedule): string {
1001
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
1002
+
1003
+ if (quartz) {
1004
+ return withEm(quartz);
1005
+ }
1006
+
1007
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
1008
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
1009
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
1010
+ const allSingles = segments.every(function single(segment) {
1011
+ return segment.kind === 'single';
1012
+ });
1013
+
1014
+ if (allSingles && segments.length === 1) {
1015
+ return recurringWeekday((segments[0] as SingleNameSegment).value);
1016
+ }
1017
+
1018
+ if (allSingles) {
1019
+ return recurringWeekdayList(segments as SingleNameSegment[]);
1020
+ }
1021
+
1022
+ // A lone weekday range reads "em qualquer dia de segunda a sexta-feira" in
1023
+ // the union: the leading "em qualquer dia" makes it a day predicate parallel
1024
+ // to the date arm ("no dia 1º de cada mês ou em qualquer dia de segunda a
1025
+ // sexta-feira"), so the union "ou" plainly joins two independent day
1026
+ // conditions.
1027
+ if (segments.length === 1) {
1028
+ return 'em qualquer dia ' +
1029
+ weekdayRange(segments[0] as RangeNameSegment);
1030
+ }
1031
+
1032
+ return mixedWeekdayList(segments);
1033
+ }
1034
+
1035
+ // The `, seja <DOM> ou <DOW>` correlative suffix for the union frame.
1036
+ function unionSejaSuffix(schedule: Schedule, opts: Opts): string {
1037
+ return ', seja ' + domArm(schedule, opts) + ' ou ' + dowArm(schedule);
1038
+ }
1039
+
1040
+ // "todos os dias às 9:30 e às 17:00".
1041
+ function renderClockTimes(
1042
+ schedule: Schedule,
1043
+ plan: Extract<PlanNode, {kind: 'clockTimes'}>,
1044
+ opts: Opts
1045
+ ): string {
1046
+ // An hour step or range (or arithmetic-progression hour list) under a single
1047
+ // pinned minute reads as a cadence or window rather than a cross-product of
1048
+ // clock times.
1049
+ if (schedule.shapes.minute === 'single') {
1050
+ const minute = +schedule.pattern.minute;
1051
+ const cadence = hourCadence(schedule, minute, opts) ??
1052
+ hourRangeCadence(schedule, minute, opts);
1053
+
1054
+ if (cadence !== null) {
1055
+ return cadence;
1056
+ }
1057
+ }
1058
+
1059
+ const phrases = plan.times.map(function clock(time) {
1060
+ return atTime(timePhrase(time.hour, time.minute, time.second, opts));
1061
+ });
1062
+
1063
+ return leadingQualifier(schedule, opts) + groupClockTimes(phrases);
1064
+ }
1065
+
1066
+ // The genitive clock-time list for a minute-0 compose-seconds confinement:
1067
+ // each time with its minute forced visible ("as 09:00"), grouped as usual,
1068
+ // then reframed from "a(s) …" to the genitive "de(s) …": "das 09:00", never
1069
+ // the bare hour.
1070
+ function explicitClockList(
1071
+ times: {hour: number; minute: number; second?: number | null}[],
1072
+ opts: Opts
1073
+ ): string {
1074
+ const phrases = times.map(function clock(time) {
1075
+ return atTime(explicitTimePhrase(time.hour, time.minute, opts));
1076
+ });
1077
+
1078
+ return degenitive(groupClockTimes(phrases));
1079
+ }
1080
+
1081
+ // The bare-hour list for a minute-0 duration confinement, keeping the "à(s) …"
1082
+ // frame the caller embeds after "durante um minuto": "às 9", "à meia-noite",
1083
+ // "às 9, 10, 11 e 12". The hour reads as a bare hour (no minutes), since the
1084
+ // "durante um minuto" frame already carries the one-minute window — never
1085
+ // "às 09:00", which would read as the whole hour.
1086
+ function durationHourList(
1087
+ times: {hour: number; minute: number; second?: number | null}[],
1088
+ opts: Opts
1089
+ ): string {
1090
+ const phrases = times.map(function clock(time) {
1091
+ return atTime(bareHourPhrase(time.hour, opts));
1092
+ });
1093
+
1094
+ return groupClockTimes(phrases);
1095
+ }
1096
+
1097
+ // A bare hour with its (bare) article, no minutes: "as 9" / "a 1" /
1098
+ // "meio-dia" / "meia-noite" on the 24-hour clock, or the 12-hour day-period
1099
+ // form ("as 9 da manhã"). The caller fuses the leading preposition. Used by
1100
+ // the minute-0 duration frame, where the minute is already stated and the
1101
+ // clock minute would only mislead.
1102
+ function bareHourPhrase(hour: number, opts: Opts): string {
1103
+ if (opts.ampm) {
1104
+ return timePhrase(hour, 0, null, opts);
1105
+ }
1106
+
1107
+ if (+hour === 0) {
1108
+ return 'meia-noite';
1109
+ }
1110
+
1111
+ if (+hour === 12) {
1112
+ return 'meio-dia';
1113
+ }
1114
+
1115
+ return (+hour === 1 ? 'a ' : 'as ') + hour;
1116
+ }
1117
+
1118
+ // A clock time with its minute forced visible and the noon/midnight words
1119
+ // suppressed: "as 09:00", "as 9:00 da manhã", "as 12:00 da tarde". So a pinned
1120
+ // minute-0 confinement always shows its ":00". Returns the bare-article form;
1121
+ // the caller fuses the preposition.
1122
+ function explicitTimePhrase(hour: number, minute: number, opts: Opts): string {
1123
+ if (!opts.ampm) {
1124
+ const article = +hour === 1 ? 'a ' : 'as ';
1125
+ const suffix = opts.style.hSuffix ? ' h' : '';
1126
+
1127
+ return article +
1128
+ clockDigits({hour, minute, second: 0},
1129
+ {pad: true, sep: opts.style.sep}) + suffix;
1130
+ }
1131
+
1132
+ const display = hour % 12 || 12;
1133
+ const time = (display === 1 ? 'a ' : 'as ') +
1134
+ clockDigits({hour: display, minute, second: 0}, {sep: opts.style.sep});
1135
+ const period = opts.style.meridiem === 'english' ?
1136
+ meridiemMark(hour) :
1137
+ dayPeriod(hour, opts);
1138
+
1139
+ return time + ' ' + period;
1140
+ }
1141
+
1142
+ // Group a chronological run of "à(s) …" clock phrases. The 12-hour clock
1143
+ // carries day periods ("da <period>"), which group chronologically by period;
1144
+ // the 24-hour clock has none, so it falls through to article-grouping.
1145
+ function groupClockTimes(phrases: string[]): string {
1146
+ if (phrases.length < 2) {
1147
+ return joinList(phrases);
1148
+ }
1149
+
1150
+ return phrases.some(carriesDayPeriod) ?
1151
+ groupClockTimesByDayPeriod(phrases) :
1152
+ groupClockTimesByArticle(phrases);
1153
+ }
1154
+
1155
+ // Whether a clock phrase carries a 12-hour day period ("às 9 da manhã");
1156
+ // 24-hour phrases ("às 09:00") never do.
1157
+ function carriesDayPeriod(phrase: string): boolean {
1158
+ return phrase.includes(' da ');
1159
+ }
1160
+
1161
+ // One parsed 12-hour clock clause. A period clause keeps its (bare) article,
1162
+ // its (chronological) values, and the day period named once;
1163
+ // meio-dia/meia-noite are special clauses carried verbatim.
1164
+ type PeriodValue = {article: 'a' | 'as'; value: string};
1165
+ type ClockClause =
1166
+ | {kind: 'period'; period: string; values: PeriodValue[]}
1167
+ | {kind: 'special'; text: string};
1168
+
1169
+ // Parse one "à(s) <value> da <period>" phrase into its parts. The article is
1170
+ // recovered from the contracted "à"/"às" head.
1171
+ const periodPhrasePattern = /^(às|à) (.+) (da .+)$/u;
1172
+
1173
+ // Group 12-hour clock phrases by day period, chronologically, never
1174
+ // reordering. Consecutive times in the same period fold into one clause that
1175
+ // names the period once; the article is shared when all values agree on it and
1176
+ // repeated per value otherwise. Two consecutive single-value clauses that
1177
+ // share a value elide to "à(s) <value> da <p1> e da <p2>". Clauses join in
1178
+ // order. (pt drops the es RAE coma ante "y"; the join is always a plain "e".)
1179
+ function groupClockTimesByDayPeriod(phrases: string[]): string {
1180
+ const runs = collectPeriodRuns(phrases);
1181
+ const elided = elideSharedSingleValues(runs);
1182
+ const rendered = elided.map(renderPeriodRun);
1183
+
1184
+ return joinPeriodClauses(rendered);
1185
+ }
1186
+
1187
+ // Fold the chronological phrases into period runs: consecutive period clauses
1188
+ // sharing a day period merge their values; specials break a run and stand
1189
+ // alone.
1190
+ function collectPeriodRuns(phrases: string[]): ClockClause[] {
1191
+ const runs: ClockClause[] = [];
1192
+
1193
+ phrases.forEach(function place(phrase): void {
1194
+ const match = periodPhrasePattern.exec(phrase);
1195
+
1196
+ if (!match) {
1197
+ runs.push({kind: 'special', text: phrase});
1198
+
1199
+ return;
1200
+ }
1201
+
1202
+ const article = match[1] === 'às' ? 'as' : 'a';
1203
+ const value = match[2];
1204
+ const period = match[3];
1205
+ const last = runs[runs.length - 1];
1206
+
1207
+ if (last && last.kind === 'period' && last.period === period) {
1208
+ last.values.push({article, value});
1209
+ }
1210
+ else {
1211
+ runs.push({kind: 'period', period, values: [{article, value}]});
1212
+ }
1213
+ });
1214
+
1215
+ return runs;
1216
+ }
1217
+
1218
+ // One rendered clause plus whether it carries an internal " e " (a multi-value
1219
+ // run or an elided clause). pt has no coma ante "e", so the flag is no longer
1220
+ // load-bearing for punctuation, but kept so the join logic mirrors the donor.
1221
+ type RenderedClause = {text: string; hasInternalE: boolean};
1222
+
1223
+ // Render one period run as "à(s) <value> da <period>", factoring the period
1224
+ // once. A shared article is named once (the contracted head); a mixed article
1225
+ // (a one-o'clock among others) repeats "à(s)" per value.
1226
+ function renderPeriodRun(clause: ClockClause): RenderedClause {
1227
+ if (clause.kind === 'special') {
1228
+ return {text: clause.text, hasInternalE: false};
1229
+ }
1230
+
1231
+ const {period, values} = clause;
1232
+
1233
+ if (values.length === 1) {
1234
+ const tail = elidedTail(clause);
1235
+
1236
+ return {
1237
+ hasInternalE: tail !== '',
1238
+ text: withA(values[0].article + ' ' + values[0].value) + ' ' + period +
1239
+ tail
1240
+ };
1241
+ }
1242
+
1243
+ const sharedArticle = values.every(function same(entry): boolean {
1244
+ return entry.article === values[0].article;
1245
+ });
1246
+ const parts = sharedArticle ?
1247
+ values.map(function bare(entry): string {
1248
+ return entry.value;
1249
+ }) :
1250
+ values.map(function articled(entry): string {
1251
+ return withA(entry.article + ' ' + entry.value);
1252
+ });
1253
+ const lead = sharedArticle ?
1254
+ withA(values[0].article + ' ') :
1255
+ '';
1256
+
1257
+ return {hasInternalE: true, text: lead + joinList(parts) + ' ' + period};
1258
+ }
1259
+
1260
+ // Elide two consecutive single-value clauses that share a clock value into one
1261
+ // clause naming each period once: "à 1 da madrugada e da tarde". Three or more
1262
+ // chain with repeated " e <period>". Only consecutive lone values merge; the
1263
+ // chronological order is never disturbed.
1264
+ function elideSharedSingleValues(runs: ClockClause[]): ClockClause[] {
1265
+ const merged: ClockClause[] = [];
1266
+ let i = 0;
1267
+
1268
+ while (i < runs.length) {
1269
+ const run = runs[i];
1270
+ const value = loneValue(run);
1271
+ let combined = run;
1272
+ let j = i + 1;
1273
+
1274
+ if (value !== null) {
1275
+ while (j < runs.length && loneValue(runs[j]) === value) {
1276
+ combined = appendPeriod(combined as ElidableClause,
1277
+ (runs[j] as ElidableClause).period);
1278
+ j += 1;
1279
+ }
1280
+ }
1281
+
1282
+ merged.push(combined);
1283
+ i = j;
1284
+ }
1285
+
1286
+ return merged;
1287
+ }
1288
+
1289
+ // A single-value period clause, the only shape the elision merges.
1290
+ type ElidableClause = Extract<ClockClause, {kind: 'period'}>;
1291
+
1292
+ // The lone clock value of a single-value period clause, else null.
1293
+ function loneValue(clause: ClockClause): string | null {
1294
+ return clause.kind === 'period' && clause.values.length === 1 ?
1295
+ clause.values[0].value :
1296
+ null;
1297
+ }
1298
+
1299
+ // Chain another period onto an elided clause: its value stays, the extra
1300
+ // period rides along under " e <period>".
1301
+ type ElidedClause = ElidableClause & {tailPeriods: string[]};
1302
+
1303
+ function appendPeriod(clause: ElidableClause, period: string): ElidedClause {
1304
+ const elided = clause as ElidedClause;
1305
+ const tailPeriods = (elided.tailPeriods || []).concat(period);
1306
+
1307
+ return {...clause, tailPeriods};
1308
+ }
1309
+
1310
+ // Render the elided-clause tail periods, or the empty string for a plain
1311
+ // clause. Reuses renderPeriodRun for the single-value head, then appends each
1312
+ // " e <period>".
1313
+ function elidedTail(clause: ClockClause): string {
1314
+ const tail = (clause as ElidedClause).tailPeriods;
1315
+
1316
+ if (!tail || tail.length === 0) {
1317
+ return '';
1318
+ }
1319
+
1320
+ return tail.map(function chain(period): string {
1321
+ return ' e ' + period;
1322
+ }).join('');
1323
+ }
1324
+
1325
+ // Join rendered period clauses in chronological order with the plain "e" join
1326
+ // (pt has no coma ante "e", unlike the es donor's RAE coma ante "y").
1327
+ function joinPeriodClauses(clauses: RenderedClause[]): string {
1328
+ if (clauses.length === 1) {
1329
+ return clauses[0].text;
1330
+ }
1331
+
1332
+ const last = clauses[clauses.length - 1];
1333
+ const lead = clauses.slice(0, -1).map(function text(clause): string {
1334
+ return clause.text;
1335
+ });
1336
+
1337
+ return lead.join(', ') + ' e ' + last.text;
1338
+ }
1339
+
1340
+ // Group clock-time phrases by article (24-hour clock): à times (1-o'clock)
1341
+ // first, then às times, each under one prefix. All-'às' and all-'à' each
1342
+ // collapse to a single prefix. When the 'às' group has exactly two items the
1343
+ // groups join with a comma to avoid a double 'e'. The 24-hour clock has no day
1344
+ // periods, so every phrase is one article form. (pt has no comma before "e".)
1345
+ function groupClockTimesByArticle(phrases: string[]): string {
1346
+ const singular = 'à ';
1347
+ const plural = 'às ';
1348
+
1349
+ const aItems: string[] = [];
1350
+ const asItems: string[] = [];
1351
+
1352
+ for (const phrase of phrases) {
1353
+ if (phrase.startsWith(plural)) {
1354
+ asItems.push(phrase.slice(plural.length));
1355
+ }
1356
+ else if (phrase.startsWith(singular)) {
1357
+ aItems.push(phrase.slice(singular.length));
1358
+ }
1359
+ else {
1360
+ // Non-article phrase (ao meio-dia, à meia-noite): plain list fallback.
1361
+ return joinList(phrases);
1362
+ }
1363
+ }
1364
+
1365
+ // All 'às': one prefix for the whole list.
1366
+ if (aItems.length === 0) {
1367
+ return plural + joinList(asItems);
1368
+ }
1369
+
1370
+ // All 'à': one shared prefix, matching the all-'às' behaviour.
1371
+ if (asItems.length === 0) {
1372
+ return singular + joinList(aItems);
1373
+ }
1374
+
1375
+ // Mixed: 'à' group first, then 'às' group. A plain comma — ", " — prevents a
1376
+ // double "e" when the join would land between two list-ending "e"s: the 'à'
1377
+ // group has two or more items, or the 'às' group has exactly two. Otherwise
1378
+ // " e " joins the two groups.
1379
+ const aPart = singular + joinList(aItems);
1380
+ const asPart = plural + joinList(asItems);
1381
+ const doubleE = aItems.length >= 2 || asItems.length === 2;
1382
+ const connector = doubleE ? ', ' : ' e ';
1383
+
1384
+ return aPart + connector + asPart;
1385
+ }
1386
+
1387
+ // Compact form past the enumeration cap: a single minute folds into
1388
+ // per-segment hour windows; a minute list leads with its own clause.
1389
+ function renderCompactClockTimes(
1390
+ schedule: Schedule,
1391
+ plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
1392
+ opts: Opts
1393
+ ): string {
1394
+ if (plan.fold) {
1395
+ // An hour step or range (or arithmetic-progression hour list) under the
1396
+ // single pinned minute reads as a cadence or window, not a wall of clock
1397
+ // times. (Returns null for an irregular list, which keeps folding below.)
1398
+ const cadence = hourCadence(schedule, plan.minute, opts) ??
1399
+ hourRangeCadence(schedule, plan.minute, opts);
1400
+
1401
+ if (cadence !== null) {
1402
+ return cadence;
1403
+ }
1404
+
1405
+ const ranged = segmentsOf(schedule, 'hour').some(function range(segment) {
1406
+ return segment.kind === 'range';
1407
+ });
1408
+
1409
+ // A folded contiguous hour range reads with the hourly cadence ("a cada
1410
+ // hora das 9:00 às 20:00 e às 22:00"), not "todos os dias".
1411
+ if (ranged && !schedule.analyses.clockSecond) {
1412
+ return 'a cada hora ' +
1413
+ hourSegmentTimes(
1414
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1415
+ ) +
1416
+ trailingQualifier(schedule, opts);
1417
+ }
1418
+
1419
+ return leadingQualifier(schedule, opts) +
1420
+ hourSegmentTimes(
1421
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
1422
+ );
1423
+ }
1424
+
1425
+ // A uneven hour stride reads as a cadence after the minute lead, not a wall
1426
+ // of clock-time columns.
1427
+ const cadence = unevenHourCadence(schedule, opts);
1428
+ const phrase = cadence ?
1429
+ minutesList(schedule, opts) + ', ' + cadence +
1430
+ trailingQualifier(schedule, opts) :
1431
+ minutesList(schedule, opts) + ', ' +
1432
+ hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
1433
+
1434
+ return schedule.analyses.clockSecond ?
1435
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
1436
+ phrase;
1437
+ }
1438
+
1439
+ // The plan dispatch table.
1440
+ const renderers = {
1441
+ clockTimes: renderClockTimes,
1442
+ compactClockTimes: renderCompactClockTimes,
1443
+ composeSeconds: renderComposeSeconds,
1444
+ everyHour: renderEveryHour,
1445
+ everyMinute: renderEveryMinute,
1446
+ everySecond: renderEverySecond,
1447
+ hourRange: renderHourRange,
1448
+ hourStep: renderHourStep,
1449
+ minuteFrequency: renderMinuteFrequency,
1450
+ minuteSpanAcrossHourStep: renderMinuteSpanAcrossHourStep,
1451
+ minuteSpanInHour: renderMinuteSpanInHour,
1452
+ minutesAcrossHours: renderMinutesAcrossHours,
1453
+ multipleMinutes: renderMultipleMinutes,
1454
+ rangeOfMinutes: renderRangeOfMinutes,
1455
+ secondPastMinute: renderSecondPastMinute,
1456
+ secondsWithinMinute: renderSecondsWithinMinute,
1457
+ singleMinute: renderSingleMinute,
1458
+ standaloneSeconds: renderStandaloneSeconds
1459
+ };
1460
+
1461
+ // --- Step phrases. ---
1462
+
1463
+ // Speak a step cadence over a `cycle`-long field (60 for minute/second). A
1464
+ // clean stride from the top of the cycle is the bare cadence ("a cada 15
1465
+ // minutos"); a uniform offset (start within the first interval, the interval
1466
+ // still dividing the cycle) names only its start, since it wraps cleanly with
1467
+ // no distinct endpoint ("a cada seis minutos a partir do minuto 5 de cada
1468
+ // hora"); a non-uniform stride (start >= interval, or an interval that does
1469
+ // not divide the cycle) pins both endpoints so the bounded, non-wrapping set
1470
+ // reads unambiguously ("a cada dois minutos do minuto 3 ao 59 de cada hora").
1471
+ // This is the one phrasing for every step the renderer speaks, whether the
1472
+ // core kept it a step shape (a clean cadence) or enumerated it to a fire list
1473
+ // (an offset/uneven set the list path recognizes as a progression).
1474
+ function renderStride(stride: Stride, opts: Opts): string {
1475
+ const {interval, start, last, cycle, unit, anchor} = stride;
1476
+ const cadence = 'a cada ' + numero(interval, opts) + ' ' + unit + 's';
1477
+
1478
+ // A context that supplies its own trailing scope passes an empty anchor, so
1479
+ // the cadence keeps its endpoints but drops the "de cada <anchor>" tail.
1480
+ const tail = anchor ? ' de cada ' + anchor : '';
1481
+
1482
+ return chooseStride({start, interval, last, cycle}, {
1483
+ bare: () => cadence,
1484
+ offset: () => cadence + ' a partir do ' + unit + ' ' + start + tail,
1485
+ bounded: () =>
1486
+ cadence + ' do ' + unit + ' ' + start + ' ao ' + last + tail
1487
+ });
1488
+ }
1489
+
1490
+ // "a cada 15 minutos", "nos minutos 5, 20 e 35 de cada hora", or "a cada 15
1491
+ // minutos a partir do minuto 5 de cada hora". A step shape only reaches here as
1492
+ // a clean cadence (the interval divides 60), so the stride collapses to the
1493
+ // bare or uniform-offset form; an offset/uneven set arrives as a fire list and
1494
+ // is recognized by the list path instead.
1495
+ function stepCycle60(
1496
+ segment: StepSegment,
1497
+ unit: string,
1498
+ anchor: string,
1499
+ opts: Opts
1500
+ ): string {
1501
+ if (segment.startToken.indexOf('-') !== -1) {
1502
+ return 'nos ' + unit + 's ' + joinList(wordList(segment.fires)) +
1503
+ ' de cada ' + anchor;
1504
+ }
1505
+
1506
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1507
+
1508
+ // A short offset cadence still lists its fires; the stride phrasing names
1509
+ // the interval and offset only once there are enough fires to beat the list.
1510
+ if (start !== 0 && segment.fires.length <= 3) {
1511
+ return 'nos ' + unit + 's ' + joinList(wordList(segment.fires)) +
1512
+ ' de cada ' + anchor;
1513
+ }
1514
+
1515
+ return renderStride({
1516
+ interval: segment.interval,
1517
+ start,
1518
+ last: segment.fires[segment.fires.length - 1],
1519
+ cycle: 60,
1520
+ unit,
1521
+ anchor
1522
+ }, opts);
1523
+ }
1524
+
1525
+ // Speak a minute/second field's enumerated fires as a step cadence when they
1526
+ // form an arithmetic progression long enough to beat the list (the core
1527
+ // enumerates an offset/uneven step to this fire list; the Schedule is
1528
+ // unchanged, so the renderer recognizes the progression). Returns null for a
1529
+ // non-progression or a too-short list, leaving the caller to enumerate.
1530
+ function strideFromSegments(
1531
+ segments: Segment[],
1532
+ unit: string,
1533
+ anchor: string,
1534
+ opts: Opts
1535
+ ): string | null {
1536
+ const values = singleValues(segments);
1537
+ const step = values && arithmeticStep(values);
1538
+
1539
+ return step ?
1540
+ renderStride({...step, cycle: 60, unit, anchor}, opts) :
1541
+ null;
1542
+ }
1543
+
1544
+
1545
+ // "a cada seis horas", "às 9:00, às 11:00 e à 1:00", or "a cada cinco horas a
1546
+ // partir das 2:00".
1547
+ function stepHours(segment: StepSegment, opts: Opts): string {
1548
+ if (segment.startToken.indexOf('-') !== -1) {
1549
+ return groupClockTimesByArticle(atTimes(segment.fires, opts));
1550
+ }
1551
+
1552
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1553
+ const interval = segment.interval;
1554
+
1555
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
1556
+ // rewritten to its fires upstream and never reaches here.)
1557
+ if (start === 0) {
1558
+ return 'a cada ' + numeroF(interval, opts) + ' horas';
1559
+ }
1560
+
1561
+ if (segment.fires.length <= 3) {
1562
+ return groupClockTimesByArticle(atTimes(segment.fires, opts));
1563
+ }
1564
+
1565
+ return 'a cada ' + numeroF(interval, opts) + ' horas a partir ' +
1566
+ fromTime(timePhrase(start, 0, null, opts));
1567
+ }
1568
+
1569
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
1570
+
1571
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride
1572
+ // from midnight is the bare cadence ("a cada duas horas"); a clean offset
1573
+ // names only its start ("a cada seis horas a partir das 02:00"); a bounded or
1574
+ // non-tiling stride pins both clock-time endpoints ("a cada duas horas das
1575
+ // 09:00 às 17:00") so the bounded set reads unambiguously. Used wherever an
1576
+ // hour step (or arithmetic-progression hour list) would otherwise be
1577
+ // cross-multiplied into a wall of clock times.
1578
+ function hourStrideCadence(
1579
+ stride: {start: number; interval: number; last: number},
1580
+ opts: Opts
1581
+ ): string {
1582
+ const {start, interval, last} = stride;
1583
+ const cadence = 'a cada ' + numeroF(interval, opts) + ' horas';
1584
+
1585
+ return chooseStride({start, interval, last, cycle: 24}, {
1586
+ bare: () => cadence,
1587
+ offset: () => cadence + ' a partir ' +
1588
+ fromTime(timePhrase(start, 0, null, opts)),
1589
+ bounded: () => cadence + ' ' +
1590
+ fromTime(timePhrase(start, 0, null, opts)) + ' ' +
1591
+ toTime(timePhrase(last, 0, null, opts))
1592
+ });
1593
+ }
1594
+
1595
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1596
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1597
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1598
+ // enumerating list paths; there the bounded hour reads better as its cadence
1599
+ // ("…, a cada cinco horas das 00:00 às 20:00") than as a wall of clock times.
1600
+ // An offset-clean stride keeps its existing confinement form, so only the
1601
+ // endpoint-bearing case routes here.
1602
+ function unevenHourCadence(schedule: Schedule, opts: Opts): string | null {
1603
+ const stride = hourStride(schedule);
1604
+
1605
+ if (!stride || offsetCleanStride(stride)) {
1606
+ return null;
1607
+ }
1608
+
1609
+ return hourStrideCadence(stride, opts);
1610
+ }
1611
+
1612
+ // The hour field's stride, or null when the hour is not a cadence: a step
1613
+ // segment yields its {start, interval, last} directly; an all-single hour
1614
+ // list yields one only when its values form a step progression (so an irregular
1615
+ // list like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
1616
+ // recognizes the stride and speaks it as a cadence instead of the clock-time
1617
+ // cross-product.
1618
+ function hourStride(
1619
+ schedule: Schedule
1620
+ ): {start: number; interval: number; last: number} | null {
1621
+ const segments = segmentsOf(schedule, 'hour');
1622
+
1623
+ if (segments.length === 1 && segments[0].kind === 'step') {
1624
+ const segment = segments[0];
1625
+
1626
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1627
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1628
+ if (segment.fires.length < 2) {
1629
+ return null;
1630
+ }
1631
+
1632
+ const start = segment.startToken === '*' ?
1633
+ 0 :
1634
+ +segment.startToken.split('-')[0];
1635
+
1636
+ return {interval: segment.interval, last: segment.fires[
1637
+ segment.fires.length - 1], start};
1638
+ }
1639
+
1640
+ const values = singleValues(segments);
1641
+
1642
+ return values && hourListStride(values);
1643
+ }
1644
+
1645
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1646
+ // fills the minute (a "durante um minuto" frame at minute 0); a single 0 is
1647
+ // just the top of the minute (no clause); anything else needs its own clause.
1648
+ function subMinuteSecond(schedule: Schedule): boolean {
1649
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1650
+ }
1651
+
1652
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1653
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single,
1654
+ // list, or range second is counted "de cada hora" (the minute-0 is the top of
1655
+ // the hour), and a wildcard or sub-minute step second takes a "durante um
1656
+ // minuto" frame (the whole minute-0 window). A non-zero minute is a real clock
1657
+ // minute: the second leads with its own clause (if any), then the minute reads
1658
+ // "no minuto M".
1659
+ function hourCadenceLead(
1660
+ schedule: Schedule, minute: number, opts: Opts
1661
+ ): string {
1662
+ if (minute === 0) {
1663
+ if (subMinuteSecond(schedule)) {
1664
+ return secondsClause(schedule, 'minuto', opts) + ' durante um minuto';
1665
+ }
1666
+
1667
+ return secondsClause(schedule, 'hora', opts);
1668
+ }
1669
+
1670
+ const minutePhrase = 'no minuto ' + minute;
1671
+
1672
+ // A single 0 second is just the top of the minute, so the minute leads
1673
+ // alone; any other second prefixes its own clause.
1674
+ if (schedule.pattern.second === '0') {
1675
+ return minutePhrase;
1676
+ }
1677
+
1678
+ return secondsClause(schedule, 'minuto', opts) + ', ' + minutePhrase;
1679
+ }
1680
+
1681
+ // Render an hour step (or arithmetic-progression hour list) under a single
1682
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1683
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1684
+ // Returns null when the hour is not a stride (an irregular list, a single
1685
+ // hour, or a range), or when the cross-product is short enough that
1686
+ // enumeration is no longer than the cadence: a meaningful second makes every
1687
+ // clock time three digit-groups, so any stride is worth compacting; otherwise
1688
+ // the stride must exceed the clock-time cap, the same point at which the core
1689
+ // itself stops enumerating. Renderer-only; the Schedule is unchanged.
1690
+ function hourCadence(
1691
+ schedule: Schedule, minute: number, opts: Opts
1692
+ ): string | null {
1693
+ const stride = hourStride(schedule);
1694
+
1695
+ if (!stride) {
1696
+ return null;
1697
+ }
1698
+
1699
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1700
+
1701
+ // A short stride that spells out as few clock times stays an enumeration only
1702
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1703
+ // or "a partir de" form is no shorter than the list. A bounded or uneven
1704
+ // stride has no clean wrap, so its endpoint-pinning cadence ("a cada cinco
1705
+ // horas das 00:00 às 20:00") reads better however short.
1706
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1707
+ offsetCleanStride(stride)) {
1708
+ return null;
1709
+ }
1710
+
1711
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1712
+ // stride is a confinement, not a juxtaposed cadence: it reads "durante um
1713
+ // minuto, durante as horas pares", reusing the hour-step confinement idiom
1714
+ // so the minute-0 window is never heard as the bare hour cadence.
1715
+ const confinement = minute === 0 && subMinuteSecond(schedule) &&
1716
+ cleanStrideSegment(schedule);
1717
+
1718
+ if (confinement) {
1719
+ return secondsClause(schedule, 'minuto', opts) + ' durante um minuto, ' +
1720
+ stepHourSpan(confinement, opts) + trailingQualifier(schedule, opts);
1721
+ }
1722
+
1723
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1724
+ // lead clause to fold in, so the bounded cadence stands on its own ("a cada
1725
+ // cinco horas das 00:00 às 20:00").
1726
+ if (minute === 0 && schedule.pattern.second === '0') {
1727
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1728
+ }
1729
+
1730
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1731
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1732
+ }
1733
+
1734
+ // The hour step segment when the hour is a clean stride pt renders as a
1735
+ // confinement phrase ("durante as horas pares"); null otherwise (an offset or
1736
+ // bounded step, an uneven stride, or an arithmetic-progression list, which
1737
+ // keep the bounded cadence form).
1738
+ function cleanStrideSegment(schedule: Schedule): StepSegment | null {
1739
+ const segments = segmentsOf(schedule, 'hour');
1740
+ const segment = segments.length === 1 && segments[0];
1741
+
1742
+ if (!segment || segment.kind !== 'step' ||
1743
+ segment.startToken.indexOf('-') !== -1) {
1744
+ return null;
1745
+ }
1746
+
1747
+ return segment;
1748
+ }
1749
+
1750
+ // Whether the hour field is a range — or a list whose segments include a
1751
+ // range — and so forms a window rather than a cross-product of clock times.
1752
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1753
+ // a step is handled by hourStride/hourCadence.
1754
+ function hasHourWindow(schedule: Schedule): boolean {
1755
+ return segmentsOf(schedule, 'hour').some(function range(segment) {
1756
+ return segment.kind === 'range';
1757
+ });
1758
+ }
1759
+
1760
+ // Render an hour range (or a list whose segments include a range) under
1761
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1762
+ // then "das 09:00 às 17:00" (and any non-contiguous hour joined with "e
1763
+ // também") — instead of cross-multiplying the hours into a wall of clock
1764
+ // times. The hour-RANGE analog of hourCadence. Returns null when the hour has
1765
+ // no range, when the minute is non-zero (a real clock minute the existing
1766
+ // window form already speaks), or when a plain :00 set carries no clause.
1767
+ // Renderer-only; the Schedule is unchanged.
1768
+ function hourRangeCadence(
1769
+ schedule: Schedule, minute: number, opts: Opts
1770
+ ): string | null {
1771
+ if (minute !== 0 || !hasHourWindow(schedule) ||
1772
+ schedule.pattern.second === '0') {
1773
+ return null;
1774
+ }
1775
+
1776
+ // A wildcard or sub-minute step second confined to minute 0 is the whole
1777
+ // minute-0 window ("durante um minuto"), confined to the hour range with the
1778
+ // "durante as horas …" idiom — kept distinct from the bare minute-0 window
1779
+ // ("a cada hora das 09:00 às 17:00") so the confinement is never heard as it
1780
+ // — the hour-range analog of "durante um minuto, durante as horas pares".
1781
+ if (subMinuteSecond(schedule)) {
1782
+ return secondsClause(schedule, 'minuto', opts) + ' durante um minuto, ' +
1783
+ 'durante as horas ' + hourSegmentTimes(schedule, 0, null, opts) +
1784
+ trailingQualifier(schedule, opts);
1785
+ }
1786
+
1787
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1788
+ hourSegmentTimes(schedule, 0, null, opts) +
1789
+ trailingQualifier(schedule, opts);
1790
+ }
1791
+
1792
+ // --- Hour-time phrasing. ---
1793
+
1794
+ // The fixed hour(s) of a stepped/listed minute, named as the HOUR rather than a
1795
+ // "às HH:00" clock instant the minute never fires at: noon and midnight read
1796
+ // as the hour word ("ao meio-dia"/"à meia-noite"), any other hour as the whole
1797
+ // hour "da hora das HH:00" (the idiom a wildcard minute already uses). Used by
1798
+ // the compact-clock non-fold path, where the minute is a step or list (a
1799
+ // single-value minute keeps its real "às HH:MM" clock time elsewhere).
1800
+ function hourContextTimes(schedule: Schedule, opts: Opts): string {
1801
+ const segments = segmentsOf(schedule, 'hour');
1802
+
1803
+ // Collect the point hours (singles and step fires) — a range stays a window.
1804
+ const points: number[] = [];
1805
+ const hasRange = segments.some(function range(segment) {
1806
+ return segment.kind === 'range';
1807
+ });
1808
+
1809
+ segments.forEach(function collect(segment) {
1810
+ if (segment.kind === 'step') {
1811
+ points.push(...segment.fires);
1812
+ }
1813
+ else if (segment.kind === 'single') {
1814
+ points.push(+segment.value);
1815
+ }
1816
+ });
1817
+
1818
+ // All point hours, all noon/midnight: stand alone as their own words ("à
1819
+ // meia-noite e ao meio-dia").
1820
+ function isWord(hour: number): boolean {
1821
+ return !opts.ampm && (hour === 0 || hour === 12);
1822
+ }
1823
+
1824
+ if (!hasRange && points.every(isWord)) {
1825
+ return joinList(points.map(function each(hour) {
1826
+ return atTime(bareHourPhrase(hour, opts));
1827
+ }));
1828
+ }
1829
+
1830
+ // A point hour as the whole hour: "da hora das HH:00".
1831
+ function wholeHour(hour: number): string {
1832
+ return 'da hora ' + fromTime(explicitTimePhrase(hour, 0, opts));
1833
+ }
1834
+
1835
+ // Otherwise each whole hour reads as a window ("das HH:00 às HH:00" for a
1836
+ // range, "da hora das HH:00" for a point), never a false "às HH:00" clock
1837
+ // instant the stepped minute never fires at.
1838
+ const pieces: string[] = [];
1839
+
1840
+ segments.forEach(function place(segment) {
1841
+ if (segment.kind === 'range') {
1842
+ pieces.push(timeRange(
1843
+ {hour: +segment.bounds[0], minute: 0},
1844
+ {hour: +segment.bounds[1], minute: 0}, opts));
1845
+ }
1846
+ else if (segment.kind === 'step') {
1847
+ segment.fires.forEach(function each(hour) {
1848
+ pieces.push(wholeHour(hour));
1849
+ });
1850
+ }
1851
+ else {
1852
+ pieces.push(wholeHour(+segment.value));
1853
+ }
1854
+ });
1855
+
1856
+ return joinList(pieces);
1857
+ }
1858
+
1859
+ // "às 9:00" / "à 1:00" / "ao meio-dia" for each fire hour.
1860
+ function atTimes(hours: number[], opts: Opts): string[] {
1861
+ return hours.map(function each(hour) {
1862
+ return atTime(timePhrase(hour, 0, null, opts));
1863
+ });
1864
+ }
1865
+
1866
+ // The hour times accompanying a lead clause: "às 9:00 e às 17:00", with long
1867
+ // expansions rendered segment by segment.
1868
+ function atHourTimes(
1869
+ schedule: Schedule,
1870
+ times: HourTimesPlan,
1871
+ opts: Opts
1872
+ ): string {
1873
+ if (times.kind === 'fires') {
1874
+ return groupClockTimesByArticle(atTimes(times.fires, opts));
1875
+ }
1876
+
1877
+ return hourSegmentTimes(schedule, 0, null, opts);
1878
+ }
1879
+
1880
+ // The active hours of a confined cadence: a few hours read as windows; many
1881
+ // read better as a compact list ("durante as horas das 9, 11, 13, 15 e 17")
1882
+ // than as a sprawl of windows.
1883
+ function hourSpanFromTimes(
1884
+ schedule: Schedule, times: HourTimesPlan, opts: Opts
1885
+ ): string {
1886
+ if (times.kind === 'fires' && times.fires.length > 3) {
1887
+ return 'durante as horas ' + hourSpanList(times.fires, opts);
1888
+ }
1889
+
1890
+ return hourWindowsFromTimes(schedule, times, opts);
1891
+ }
1892
+
1893
+ // Each fire hour as its own one-hour window: "das 9:00 às 9:59 e das 17:00 às
1894
+ // 17:59". Portuguese prefers this to the English "during the 9 a.m. and 5 p.m.
1895
+ // hours" shape.
1896
+ function hourWindowsFromTimes(
1897
+ schedule: Schedule,
1898
+ times: HourTimesPlan,
1899
+ opts: Opts
1900
+ ): string {
1901
+ if (times.kind === 'fires') {
1902
+ return joinList(times.fires.map(function window(hour) {
1903
+ return hourAsWindow(hour, opts);
1904
+ }));
1905
+ }
1906
+
1907
+ return joinList(segmentsOf(schedule, 'hour').map(function window(segment) {
1908
+ if (segment.kind === 'range') {
1909
+ return timeRange({hour: +segment.bounds[0], minute: 0},
1910
+ {hour: +segment.bounds[1], minute: 59}, opts);
1911
+ }
1912
+
1913
+ if (segment.kind === 'step') {
1914
+ return joinList(segment.fires.map(function each(hour) {
1915
+ return hourAsWindow(hour, opts);
1916
+ }));
1917
+ }
1918
+
1919
+ return hourAsWindow(+segment.value, opts);
1920
+ }));
1921
+ }
1922
+
1923
+ // Clock times for the hour field rendered segment by segment, the minute
1924
+ // (and optional second) folded into each: "das 9:30 às 20:30 e também às
1925
+ // 22:30" when an isolated point-time follows a range.
1926
+ function hourSegmentTimes(
1927
+ schedule: Schedule,
1928
+ minute: number,
1929
+ second: number | null | undefined,
1930
+ opts: Opts
1931
+ ): string {
1932
+ // Track whether each piece came from a range (true) or a point (false).
1933
+ const pieces: string[] = [];
1934
+ const fromRange: boolean[] = [];
1935
+
1936
+ segmentsOf(schedule, 'hour').forEach(function clock(segment) {
1937
+ if (segment.kind === 'step') {
1938
+ segment.fires.forEach(function each(hour) {
1939
+ pieces.push(atTime(timePhrase(hour, minute, second, opts)));
1940
+ fromRange.push(false);
1941
+ });
1942
+ }
1943
+ else if (segment.kind === 'range') {
1944
+ pieces.push(timeRange(
1945
+ {hour: +segment.bounds[0], minute, second},
1946
+ {hour: +segment.bounds[1], minute, second}, opts));
1947
+ fromRange.push(true);
1948
+ }
1949
+ else {
1950
+ pieces.push(atTime(timePhrase(+segment.value, minute, second, opts)));
1951
+ fromRange.push(false);
1952
+ }
1953
+ });
1954
+
1955
+ // When the last piece is an isolated point-time that follows a range, join it
1956
+ // with "e também" so it is not read as the range extending.
1957
+ const lastIdx = pieces.length - 1;
1958
+ const hasRange = fromRange.some(function ranged(r) {
1959
+ return r;
1960
+ });
1961
+ const lastIsPoint = lastIdx >= 1 && !fromRange[lastIdx] &&
1962
+ fromRange[lastIdx - 1];
1963
+
1964
+ if (hasRange && lastIsPoint) {
1965
+ return joinList(pieces.slice(0, lastIdx)) + ' e também ' + pieces[lastIdx];
1966
+ }
1967
+
1968
+ return groupClockTimesByArticle(pieces);
1969
+ }
1970
+
1971
+ // --- Times. ---
1972
+
1973
+ // A time range, "das 9:00 às 5:45 da tarde", between two `{hour, minute,
1974
+ // second}` ends. When both ends share a day period it is said once, at the end.
1975
+ function timeRange(
1976
+ from: ClockEnd,
1977
+ to: ClockEnd,
1978
+ opts: Opts
1979
+ ): string {
1980
+ const fromPhrase = timePhrase(from.hour, from.minute, from.second, opts);
1981
+ const toPhrase = timePhrase(to.hour, to.minute, to.second, opts);
1982
+ const fromPeriod = dayPeriod(from.hour, opts);
1983
+ const toPeriod = dayPeriod(to.hour, opts);
1984
+
1985
+ if (fromPeriod && fromPeriod === toPeriod &&
1986
+ fromPhrase.endsWith(fromPeriod)) {
1987
+ return fromTime(stripPeriod(fromPhrase, fromPeriod)) + ' ' +
1988
+ toTime(toPhrase);
1989
+ }
1990
+
1991
+ return fromTime(fromPhrase) + ' ' + toTime(toPhrase);
1992
+ }
1993
+
1994
+ // A one-hour window, "das 9:00 às 9:59".
1995
+ function hourAsWindow(hour: number, opts: Opts): string {
1996
+ return timeRange({hour, minute: 0}, {hour, minute: 59}, opts);
1997
+ }
1998
+
1999
+ // Drop a shared day period from the first end of a range.
2000
+ function stripPeriod(phrase: string, period: string): string {
2001
+ return phrase.slice(0, -(period.length + 1));
2002
+ }
2003
+
2004
+ // "às 9:30" / "à 1:00" / "ao meio-dia" / "à meia-noite". The phrase carries a
2005
+ // bare article, so `a` fuses with it (a+as=às, a+a=à, a+o=ao).
2006
+ function atTime(phrase: string): string {
2007
+ return withA(phrase);
2008
+ }
2009
+
2010
+ // "das 9:30" / "do meio-dia" / "da meia-noite". `de` fuses with the bare
2011
+ // article (de+as=das, de+a=da, de+o=do).
2012
+ function fromTime(phrase: string): string {
2013
+ return withDe(phrase);
2014
+ }
2015
+
2016
+ // "às 17:45" as the closing end of a range.
2017
+ function toTime(phrase: string): string {
2018
+ return atTime(phrase);
2019
+ }
2020
+
2021
+ // Reframe an "à(s) …" grouped clock list to the genitive "de(s) …" form by
2022
+ // re-contracting the leading preposition: "às 09:00" -> "das 09:00", "à 01:00"
2023
+ // -> "da 01:00". The grouping already factored the article into the head, so
2024
+ // only that head is rewritten.
2025
+ function degenitive(grouped: string): string {
2026
+ if (grouped.startsWith('às ')) {
2027
+ return 'das ' + grouped.slice(3);
2028
+ }
2029
+
2030
+ if (grouped.startsWith('à ')) {
2031
+ return 'da ' + grouped.slice(2);
2032
+ }
2033
+
2034
+ return grouped;
2035
+ }
2036
+
2037
+ // A clock time with its article: "as 9:30 da manhã", "a 1 da tarde",
2038
+ // "meio-dia", or "as 17:45" in 24-hour mode. The article is bare so the
2039
+ // preposition contracts at the "at"/"from" boundary. On-the-hour times drop
2040
+ // their minutes; exact 12:00 reads as a word.
2041
+ function timePhrase(
2042
+ hour: number,
2043
+ minute: number,
2044
+ second: number | null | undefined,
2045
+ opts: Opts
2046
+ ): string {
2047
+ const showSeconds = typeof second === 'number' && second > 0 ? second : 0;
2048
+
2049
+ if (!opts.ampm) {
2050
+ // One o'clock takes the singular article ("a 01:00") even on the 24-hour
2051
+ // clock; every other hour is plural ("as 13:00"). Hours are zero-padded to
2052
+ // two digits, like the minutes.
2053
+ const article = +hour === 1 ? 'a ' : 'as ';
2054
+ const suffix = opts.style.hSuffix ? ' h' : '';
2055
+
2056
+ return article +
2057
+ clockDigits({hour, minute, second: showSeconds},
2058
+ {pad: true, sep: opts.style.sep}) + suffix;
2059
+ }
2060
+
2061
+ return twelveHourPhrase(hour, minute, showSeconds, opts);
2062
+ }
2063
+
2064
+ // The 12-hour phrase with its (bare) article and day period.
2065
+ function twelveHourPhrase(
2066
+ hour: number,
2067
+ minute: number,
2068
+ second: number,
2069
+ opts: Opts
2070
+ ): string {
2071
+ if (+minute === 0 && !second) {
2072
+ if (+hour === 0) {
2073
+ return 'meia-noite';
2074
+ }
2075
+
2076
+ if (+hour === 12) {
2077
+ return 'meio-dia';
2078
+ }
2079
+ }
2080
+
2081
+ const display = hour % 12 || 12;
2082
+ const time = (display === 1 ? 'a ' : 'as ') +
2083
+ clockDigits({hour: display, minute, second},
2084
+ {lean: true, sep: opts.style.sep});
2085
+
2086
+ const period = opts.style.meridiem === 'english'
2087
+ ? meridiemMark(hour)
2088
+ : dayPeriod(hour, opts);
2089
+
2090
+ return time + ' ' + period;
2091
+ }
2092
+
2093
+ // The English meridiem mark: "AM" before noon, "PM" from noon. No shipped pt
2094
+ // dialect uses it; kept for parity with the donor scaffold.
2095
+ function meridiemMark(hour: number): string {
2096
+ return +hour < 12 ? 'AM' : 'PM';
2097
+ }
2098
+
2099
+ // The Portuguese day period for an hour: "da madrugada" (1-5), "da manhã"
2100
+ // (6-11), "da tarde" (12-18), or "da noite" (19-23 and midnight's hour). The
2101
+ // pt-BR panel unanimously ratified the noite boundary at 19h (the
2102
+ // broadcast/weather register and the "jornal da noite" anchor; see notes.md),
2103
+ // earlier than the es donor's 20h: 18h reads "da tarde", 19h+ "da noite" (e.g.
2104
+ // "1/3" → "7 da noite" at 19h). Empty in 24-hour mode.
2105
+ function dayPeriod(hour: number, opts: Opts): string {
2106
+ if (!opts.ampm) {
2107
+ return '';
2108
+ }
2109
+
2110
+ if (+hour === 0 || +hour >= 19) {
2111
+ return 'da noite';
2112
+ }
2113
+
2114
+ if (+hour <= 5) {
2115
+ return 'da madrugada';
2116
+ }
2117
+
2118
+ if (+hour <= 11) {
2119
+ return 'da manhã';
2120
+ }
2121
+
2122
+ return 'da tarde';
2123
+ }
2124
+
2125
+ // --- Day-level qualifiers. ---
2126
+
2127
+ // The qualifier that precedes clock times: "todos os dias ", "toda
2128
+ // segunda-feira ", "no dia 13 de cada mês ", "de segunda a sexta-feira ".
2129
+ // Date-OR-weekday unions skip this entirely — the unified frame in `render`
2130
+ // handles the month lead and day-level suffix.
2131
+ function leadingQualifier(schedule: Schedule, opts: Opts): string {
2132
+ const pattern = schedule.pattern;
2133
+
2134
+ if (pattern.date !== '*' && pattern.weekday !== '*') {
2135
+ return '';
2136
+ }
2137
+
2138
+ if (pattern.date !== '*') {
2139
+ return datePhrase(schedule, opts) + ' ';
2140
+ }
2141
+
2142
+ if (pattern.weekday !== '*') {
2143
+ return weekdayLead(schedule) + ' ';
2144
+ }
2145
+
2146
+ if (pattern.month !== '*') {
2147
+ return 'todos os dias ' + monthPhrase(schedule, 'de ') + ' ';
2148
+ }
2149
+
2150
+ return 'todos os dias ';
2151
+ }
2152
+
2153
+ // The day qualifier for a clause that TRAILS after a comma (e.g. "…, às
2154
+ // segundas-feiras"). It mirrors leadingQualifier but, being non-leading, the
2155
+ // weekday reads the plural recurrence ("às segundas-feiras"), never the leading
2156
+ // "toda X" head, and the plain "todos os dias" survives where trailingQualifier
2157
+ // would drop it. Returns no surrounding spaces; the caller sets the comma.
2158
+ function trailingDayClause(schedule: Schedule, opts: Opts): string {
2159
+ const pattern = schedule.pattern;
2160
+
2161
+ if (pattern.date !== '*' && pattern.weekday !== '*') {
2162
+ return '';
2163
+ }
2164
+
2165
+ if (pattern.date !== '*') {
2166
+ return datePhrase(schedule, opts);
2167
+ }
2168
+
2169
+ if (pattern.weekday !== '*') {
2170
+ return weekdayQualifier(schedule) + monthScope(schedule);
2171
+ }
2172
+
2173
+ if (pattern.month !== '*') {
2174
+ return 'todos os dias ' + monthPhrase(schedule, 'de ');
2175
+ }
2176
+
2177
+ return 'todos os dias';
2178
+ }
2179
+
2180
+ // The qualifier trailing a frequency: " às segundas-feiras", " em junho", " no
2181
+ // dia 13 de cada mês". Empty when no day-level field is set.
2182
+ // Date-OR-weekday unions skip this entirely — the unified frame in `render`
2183
+ // handles the month lead and day-level suffix.
2184
+ function trailingQualifier(schedule: Schedule, opts: Opts): string {
2185
+ const pattern = schedule.pattern;
2186
+
2187
+ if (pattern.date !== '*' && pattern.weekday !== '*') {
2188
+ return '';
2189
+ }
2190
+
2191
+ if (pattern.date !== '*') {
2192
+ return ' ' + datePhrase(schedule, opts);
2193
+ }
2194
+
2195
+ if (pattern.weekday !== '*') {
2196
+ return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
2197
+ }
2198
+
2199
+ if (pattern.month !== '*') {
2200
+ return ' ' + monthPhrase(schedule, 'em ');
2201
+ }
2202
+
2203
+ return '';
2204
+ }
2205
+
2206
+ // The leading weekday qualifier before a clock time. A single FEMININE weekday
2207
+ // (a -feira day) directly followed by a clock time reads the singular "toda X"
2208
+ // head (notes.md: kills the double-"às" of "às segundas-feiras às 9 …"). A
2209
+ // masculine single weekday (domingo/sábado) recurs as "aos domingos" (its "aos"
2210
+ // never clashes with the time's "à(s)") so it keeps the plural form, as does
2211
+ // any list/range and any weekday under a ranged month scope (a comma then sets
2212
+ // the time off, so there is no adjacency to clash). A month scope rides along.
2213
+ function weekdayLead(schedule: Schedule): string {
2214
+ const single = singleFeminineWeekday(schedule);
2215
+
2216
+ if (single !== null && !monthRanged(schedule)) {
2217
+ return everyWeekday(single) + monthScope(schedule);
2218
+ }
2219
+
2220
+ return weekdayQualifier(schedule) + monthScope(schedule);
2221
+ }
2222
+
2223
+ // The canonical weekday number when the field is exactly one plain FEMININE
2224
+ // weekday (a single -feira day, not Quartz), else null. The "toda X" head
2225
+ // applies only to this shape (its "às" recurrence is the one that clashes).
2226
+ function singleFeminineWeekday(schedule: Schedule): number | null {
2227
+ if (quartzWeekdayPhrase(schedule.pattern.weekday)) {
2228
+ return null;
2229
+ }
2230
+
2231
+ const segments = segmentsOf(schedule, 'weekday');
2232
+
2233
+ if (segments.length === 1 && segments[0].kind === 'single') {
2234
+ const number = canonicalWeekday(segments[0].value);
2235
+
2236
+ return weekdayFeminine(number) ? number : null;
2237
+ }
2238
+
2239
+ return null;
2240
+ }
2241
+
2242
+ // "toda segunda-feira": the singular recurrence head for a single feminine
2243
+ // weekday leading a clock time.
2244
+ function everyWeekday(number: number): string {
2245
+ return 'toda ' + weekdayNames[number];
2246
+ }
2247
+
2248
+ // The date qualifier: "no dia 13 de junho", "nos dias 1º e 15 de cada mês",
2249
+ // "do dia 1º ao dia 15 de cada mês", or a Quartz phrase. A foldable single
2250
+ // year joins the date ("no dia 25 de dezembro de 2030").
2251
+ function datePhrase(schedule: Schedule, opts: Opts): string {
2252
+ const pattern = schedule.pattern;
2253
+
2254
+ if (quartzDatePhrase(pattern.date) || isOpenStep(pattern.date)) {
2255
+ return dateClause(schedule, '', opts) + monthScope(schedule);
2256
+ }
2257
+
2258
+ return dateClause(schedule, dateMonthPart(schedule), opts);
2259
+ }
2260
+
2261
+ // The date words with a caller-chosen month part. Quartz phrases and open
2262
+ // steps are self-contained and ignore the month part.
2263
+ function dateClause(
2264
+ schedule: Schedule,
2265
+ monthPart: string,
2266
+ opts: Opts
2267
+ ): string {
2268
+ const pattern = schedule.pattern;
2269
+ const quartz = quartzDatePhrase(pattern.date);
2270
+
2271
+ if (quartz) {
2272
+ return hasLeadingArticle(quartz) ? withEm(quartz) : quartz;
2273
+ }
2274
+
2275
+ if (isOpenStep(pattern.date)) {
2276
+ return stepDates(pattern.date, opts);
2277
+ }
2278
+
2279
+ const segments = segmentsOf(schedule, 'date');
2280
+
2281
+ if (segments.length === 1 && segments[0].kind === 'range') {
2282
+ return 'do dia ' + dayOrdinal(segments[0].bounds[0]) + ' ao dia ' +
2283
+ segments[0].bounds[1] + monthPart + foldedYear(schedule);
2284
+ }
2285
+
2286
+ if (segments.length === 1 && segments[0].kind === 'single') {
2287
+ return 'no dia ' + dayOrdinal(segments[0].value) + monthPart +
2288
+ foldedYear(schedule);
2289
+ }
2290
+
2291
+ return 'nos dias ' + joinList(dateWords(segments)) + monthPart +
2292
+ foldedYear(schedule);
2293
+ }
2294
+
2295
+ // Whether the month field contains a range segment.
2296
+ function monthRanged(schedule: Schedule): boolean {
2297
+ return schedule.pattern.month !== '*' &&
2298
+ segmentsOf(schedule, 'month').some(function range(segment) {
2299
+ return segment.kind === 'range';
2300
+ });
2301
+ }
2302
+
2303
+ // The month attached to a calendar date. Single months and flat name lists
2304
+ // fold in ("no dia 1º de junho e dezembro"), but a range cannot — "no dia 1º
2305
+ // de junho a setembro" parses as "(no dia 1º de junho) a setembro" — so it
2306
+ // scopes the date instead ("no dia 1º de cada mês, de junho a setembro").
2307
+ function dateMonthPart(schedule: Schedule): string {
2308
+ if (schedule.pattern.month === '*') {
2309
+ return ' de cada mês';
2310
+ }
2311
+
2312
+ if (monthRanged(schedule)) {
2313
+ return ' de cada mês, ' + monthPhrase(schedule, 'de ');
2314
+ }
2315
+
2316
+ return ' ' + monthPhrase(schedule, 'de ');
2317
+ }
2318
+
2319
+ // "de 2030" when a single year can fold into a calendar date.
2320
+ function foldedYear(schedule: Schedule): string {
2321
+ const yearField = schedule.pattern.year;
2322
+
2323
+ if (yearField === '*' || yearField.indexOf('/') !== -1 ||
2324
+ yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
2325
+ return '';
2326
+ }
2327
+
2328
+ return ' de ' + yearField;
2329
+ }
2330
+
2331
+ // The Quartz date phrases. Each begins with a bare article so the caller can
2332
+ // fuse a preposition (em+o=no, de+o=do): "o último dia do mês".
2333
+ function quartzDatePhrase(dateField: string): string | undefined {
2334
+ if (dateField === 'L') {
2335
+ return 'o último dia do mês';
2336
+ }
2337
+
2338
+ if (dateField === 'LW' || dateField === 'WL') {
2339
+ return 'o último dia útil do mês';
2340
+ }
2341
+
2342
+ const offset = (/^L-(\d{1,2})$/).exec(dateField);
2343
+
2344
+ if (offset) {
2345
+ return +offset[1] === 1 ?
2346
+ 'um dia antes do último dia do mês' :
2347
+ offset[1] + ' dias antes do último dia do mês';
2348
+ }
2349
+
2350
+ const nearest = (/^(\d{1,2})W$|^W(\d{1,2})$/).exec(dateField);
2351
+
2352
+ if (nearest) {
2353
+ // The W-operator proximity takes the dative "próximo ao dia 15" (a+o=ao),
2354
+ // not "próximo do" (notes.md).
2355
+ return 'o dia útil mais próximo ao dia ' +
2356
+ (nearest[1] || nearest[2]);
2357
+ }
2358
+ }
2359
+
2360
+ // The Quartz weekday phrases: "a última sexta-feira do mês", "a segunda
2361
+ // segunda-feira do mês". The nth-weekday ordinal agrees with the weekday's
2362
+ // gender; when the ordinal WORD would collide with the weekday name (the "#2"
2363
+ // of Monday spelling "segunda segunda-feira"), the ordinal digit is used
2364
+ // instead ("a 2ª segunda-feira do mês"). Each phrase begins with a bare
2365
+ // article so the caller can fuse a preposition (em+a=na, em+o=no).
2366
+ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
2367
+ const parts = weekdayField.split('#');
2368
+
2369
+ if (parts.length === 2) {
2370
+ const number = canonicalWeekday(parts[0]);
2371
+ const feminine = weekdayFeminine(number);
2372
+ const ordinalWord = (feminine ? nthWeekdayFeminine : nthWeekdayMasculine)[
2373
+ +parts[1]];
2374
+ const article = feminine ? 'a ' : 'o ';
2375
+
2376
+ // The ordinal word collides with the weekday name when it shares the stem
2377
+ // (the "segunda" of segunda-feira, etc.): use the ordinal digit "Nª"/"Nº".
2378
+ if (ordinalCollides(ordinalWord, number)) {
2379
+ return article + parts[1] + (feminine ? 'ª ' : 'º ') +
2380
+ weekdayNames[number] + ' do mês';
2381
+ }
2382
+
2383
+ return article + ordinalWord + ' ' +
2384
+ weekdayNames[number] + ' do mês';
2385
+ }
2386
+
2387
+ if ((/L$/).test(weekdayField)) {
2388
+ const number = canonicalWeekday(weekdayField.slice(0, -1));
2389
+ const article = weekdayFeminine(number) ? 'a última ' : 'o último ';
2390
+
2391
+ return article + weekdayNames[number] + ' do mês';
2392
+ }
2393
+ }
2394
+
2395
+ // Whether an ordinal word would read as a homograph of the weekday name — i.e.
2396
+ // the ordinal shares the weekday's bare stem (segunda/terça/quarta/quinta).
2397
+ // "a segunda segunda-feira" is unreadable, so the digit form is used instead.
2398
+ function ordinalCollides(ordinalWord: string | null, number: number): boolean {
2399
+ return ordinalWord === weekdayStems[number];
2400
+ }
2401
+
2402
+ // The weekday qualifier (the trailing/standalone recurrence form): "às
2403
+ // segundas-feiras", "de segunda a sexta-feira", "às segundas, quartas e
2404
+ // sextas-feiras". The plural recurrence "às [weekday]s-feiras" already conveys
2405
+ // "every Monday".
2406
+ function weekdayQualifier(schedule: Schedule): string {
2407
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
2408
+
2409
+ if (quartz) {
2410
+ return withEm(quartz);
2411
+ }
2412
+
2413
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
2414
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
2415
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
2416
+ const allSingles = segments.every(function single(segment) {
2417
+ return segment.kind === 'single';
2418
+ });
2419
+
2420
+ if (allSingles) {
2421
+ return recurringWeekdayList(segments as SingleNameSegment[]);
2422
+ }
2423
+
2424
+ // A single plain range stands alone: "de segunda a sexta-feira". Reaching
2425
+ // here means not all-singles with a single segment, i.e. a lone range.
2426
+ if (segments.length === 1) {
2427
+ return weekdayRange(segments[0] as RangeNameSegment);
2428
+ }
2429
+
2430
+ // Mixed lists: each piece carries its own form.
2431
+ return mixedWeekdayList(segments);
2432
+ }
2433
+
2434
+ // The recurrence for a single weekday: "às segundas-feiras" (feminine, a+as=às)
2435
+ // / "aos domingos" (masculine, a+os=aos).
2436
+ function recurringWeekday(token: NameToken): string {
2437
+ const number = canonicalWeekday(token);
2438
+ const article = weekdayFeminine(number) ? 'as ' : 'os ';
2439
+
2440
+ return withA(article + pluralWeekday(token));
2441
+ }
2442
+
2443
+ // A list of single weekdays as the recurrence. The feminine -feira days lead
2444
+ // under one shared "às" head, with the "-feira" suffix on the LAST -feira day
2445
+ // only (the idiomatic pt-BR suffix-ellipsis): "às segundas, quartas e
2446
+ // sextas-feiras". A SINGLE masculine day trailing the feminine run takes its
2447
+ // own contracted article ("às …-feiras e aos domingos") — "às" cannot govern a
2448
+ // masculine noun — while a RUN of trailing masculine days (sábado+domingo)
2449
+ // stays under the shared "às" head ("às terças, quintas-feiras, sábados e
2450
+ // domingos"), the form the pt-BR panel affirmed. An all-masculine list takes
2451
+ // the masculine recurrence outright ("aos domingos").
2452
+ function recurringWeekdayList(segments: SingleNameSegment[]): string {
2453
+ const numbers = segments.map(function num(segment) {
2454
+ return canonicalWeekday(segment.value);
2455
+ });
2456
+
2457
+ const feminineCount = numbers.filter(weekdayFeminine).length;
2458
+
2459
+ // All masculine: the whole list takes the masculine recurrence.
2460
+ if (feminineCount === 0) {
2461
+ return withA('os ' + joinList(numbers.map(pluralFeira)));
2462
+ }
2463
+
2464
+ // A single masculine day trailing the feminine run splits into its own
2465
+ // contracted "ao(s)" group; a longer trailing run stays under "às".
2466
+ const trailingMasculine = numbers.length - feminineCount === 1 &&
2467
+ !weekdayFeminine(numbers[numbers.length - 1]);
2468
+ const head = trailingMasculine ? numbers.slice(0, -1) : numbers;
2469
+ const tail = trailingMasculine ? numbers[numbers.length - 1] : null;
2470
+
2471
+ if (tail === null) {
2472
+ return withA('as ' + joinList(feiraEllipsis(head)));
2473
+ }
2474
+
2475
+ // The feminine head joins with commas only — the terminal "e" connects it to
2476
+ // the split masculine tail: "às segundas, quartas, sextas-feiras e aos
2477
+ // domingos".
2478
+ const feminineList = withA('as ' + feiraEllipsis(head).join(', '));
2479
+
2480
+ return feminineList + ' e ' + withA('os ' + pluralFeira(tail));
2481
+ }
2482
+
2483
+ // The plural weekday words for a list, with the "-feira" suffix kept on the
2484
+ // LAST -feira day only and elided from the earlier ones (the pt-BR
2485
+ // suffix-ellipsis); masculine days carry their full plural.
2486
+ function feiraEllipsis(numbers: number[]): string[] {
2487
+ let lastFeira = -1;
2488
+
2489
+ numbers.forEach(function find(number, index) {
2490
+ if (weekdayFeminine(number)) {
2491
+ lastFeira = index;
2492
+ }
2493
+ });
2494
+
2495
+ return numbers.map(function word(number, index) {
2496
+ if (weekdayFeminine(number)) {
2497
+ return index === lastFeira ? pluralFeira(number) : pluralStem(number);
2498
+ }
2499
+
2500
+ return pluralFeira(number);
2501
+ });
2502
+ }
2503
+
2504
+ // A mixed weekday list (ranges + singles), each piece carrying its own form:
2505
+ // ranges read "de X a Y-feira", singles read the recurrence "às Xs-feiras" /
2506
+ // "aos domingos". Used in the standalone qualifier and the OR-union dow arm.
2507
+ function mixedWeekdayList(segments: NameSegment[]): string {
2508
+ return joinList(segments.map(function name(segment) {
2509
+ return segment.kind === 'range' ?
2510
+ weekdayRange(segment) :
2511
+ recurringWeekday(segment.value);
2512
+ }));
2513
+ }
2514
+
2515
+ // "de segunda a sexta-feira": the range carries the "-feira" on the LAST term
2516
+ // only (the idiomatic pt-BR range shorthand), and the bare stem on the first.
2517
+ function weekdayRange(segment: RangeNameSegment): string {
2518
+ return 'de ' + weekdayStem(segment.bounds[0]) + ' a ' +
2519
+ weekdayName(segment.bounds[1]);
2520
+ }
2521
+
2522
+ // Expand step segments into their fires as singles: a raw step token or a
2523
+ // nested sub-list garbles a name list, while the flat fires read naturally
2524
+ // ("às segundas, quartas e sextas-feiras").
2525
+ function flattenSteps(segments: Segment[]): NameSegment[] {
2526
+ return segments.flatMap(function flat(segment): NameSegment[] {
2527
+ return segment.kind === 'step' ?
2528
+ segment.fires.map(function single(value): NameSegment {
2529
+ return {kind: 'single', value};
2530
+ }) :
2531
+ [segment];
2532
+ });
2533
+ }
2534
+
2535
+ // The month qualifier with its preposition. Plain name lists distribute the
2536
+ // caller's preposition ("de junho e dezembro", "em janeiro e julho"); step
2537
+ // segments flatten into their fires. A range always reads "de X a Y" as one
2538
+ // unit, so in mixed lists every piece repeats its preposition ("em janeiro e
2539
+ // de março a junho") — a bare "janeiro e março a junho" parses as "(janeiro e
2540
+ // março) a junho".
2541
+ function monthPhrase(schedule: Schedule, lead: string): string {
2542
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
2543
+ const ranged = segments.some(function range(segment) {
2544
+ return segment.kind === 'range';
2545
+ });
2546
+
2547
+ if (!ranged) {
2548
+ // No ranges remain, so every segment is a single with a `value`.
2549
+ return lead + joinList(segments.map(function name(segment) {
2550
+ return monthName((segment as SingleNameSegment).value);
2551
+ }));
2552
+ }
2553
+
2554
+ return joinList(segments.map(function name(segment) {
2555
+ if (segment.kind === 'range') {
2556
+ return 'de ' + monthName(segment.bounds[0]) + ' a ' +
2557
+ monthName(segment.bounds[1]);
2558
+ }
2559
+
2560
+ return lead + monthName(segment.value);
2561
+ }));
2562
+ }
2563
+
2564
+ // A trailing " de <month>" scope on weekday qualifiers ("às segundas-feiras de
2565
+ // junho"). A ranged scope sets off with a comma ("o último dia do mês, de
2566
+ // junho a setembro") — gluing "de junho" after "do mês" garden-paths.
2567
+ function monthScope(schedule: Schedule): string {
2568
+ if (schedule.pattern.month === '*') {
2569
+ return '';
2570
+ }
2571
+
2572
+ return (monthRanged(schedule) ? ', ' : ' ') + monthPhrase(schedule, 'de ');
2573
+ }
2574
+
2575
+ // The parity predicate for a `*/2`-style day-of-month step, used only inside
2576
+ // the OR union frame (see domArm). `*/2` and `1/2` fire on the odd days
2577
+ // (1, 3, …, 31); `2/2` fires on the even days. Any other open step has no
2578
+ // parity reading, so the caller falls back to stepDates.
2579
+ function parityDayPredicate(dateField: string): string | undefined {
2580
+ if (!isOpenStep(dateField)) {
2581
+ return;
2582
+ }
2583
+
2584
+ const [start, step] = dateField.split('/');
2585
+
2586
+ if (+step !== 2) {
2587
+ return;
2588
+ }
2589
+
2590
+ if (start === '*' || start === '1') {
2591
+ return 'um dia ímpar do mês';
2592
+ }
2593
+
2594
+ if (start === '2') {
2595
+ return 'um dia par do mês';
2596
+ }
2597
+ }
2598
+
2599
+ // Open day-of-month steps: "a cada 2 dias do mês (a partir do dia 5)". Begins
2600
+ // with a bare lead the caller may fuse where needed.
2601
+ function stepDates(dateField: string, opts: Opts): string {
2602
+ const parts = dateField.split('/');
2603
+ let phrase = 'a cada ' + numero(+parts[1], opts) + ' dias do mês';
2604
+
2605
+ if (parts[0] !== '*' && parts[0] !== '1') {
2606
+ phrase += ' a partir do dia ' + dayOrdinal(parts[0]);
2607
+ }
2608
+
2609
+ return phrase;
2610
+ }
2611
+
2612
+ // --- Years. ---
2613
+
2614
+ // Append the year when it has not folded into a calendar date: "em 2030", "em
2615
+ // 2030, 2031 e 2032", "a cada dois anos a partir de 2030".
2616
+ function applyYear(
2617
+ description: string,
2618
+ schedule: Schedule,
2619
+ opts: Opts
2620
+ ): string {
2621
+ const yearField = schedule.pattern.year;
2622
+
2623
+ if (yearField === '*') {
2624
+ return description;
2625
+ }
2626
+
2627
+ if (yearField.indexOf('/') !== -1) {
2628
+ return description + ' ' + stepYears(yearField, opts);
2629
+ }
2630
+
2631
+ // A foldable single year already joined its date in datePhrase.
2632
+ if (foldedYear(schedule) && schedule.pattern.date !== '*') {
2633
+ return description;
2634
+ }
2635
+
2636
+ if (yearField.indexOf(',') !== -1) {
2637
+ return description + ' em ' + joinList(yearField.split(','));
2638
+ }
2639
+
2640
+ return description + ' em ' + yearField;
2641
+ }
2642
+
2643
+ // "a cada dois anos (a partir de 2030)" / "todos os anos".
2644
+ function stepYears(yearField: string, opts: Opts): string {
2645
+ const parts = yearField.split('/');
2646
+ const interval = +parts[1];
2647
+
2648
+ if (interval <= 1) {
2649
+ return 'todos os anos';
2650
+ }
2651
+
2652
+ let phrase = 'a cada ' + numero(interval, opts) + ' anos';
2653
+
2654
+ if (parts[0] !== '*' && parts[0] !== '0') {
2655
+ phrase += ' a partir de ' + parts[0];
2656
+ }
2657
+
2658
+ return phrase;
2659
+ }
2660
+
2661
+ // --- Words. ---
2662
+
2663
+ // Render classified segments as words: ranges as "5 a 10" pairs, steps as
2664
+ // their enumerated fires.
2665
+ function segmentWords(segments: Segment[]): string[] {
2666
+ return segments.flatMap(function word(segment) {
2667
+ if (segment.kind === 'range') {
2668
+ return [segment.bounds[0] + ' a ' + segment.bounds[1]];
2669
+ }
2670
+
2671
+ if (segment.kind === 'step') {
2672
+ return wordList(segment.fires);
2673
+ }
2674
+
2675
+ return [segment.value];
2676
+ });
2677
+ }
2678
+
2679
+ // Render date segments as words, with the 1st of the month as the ordinal
2680
+ // "1º" and every other day cardinal. Ranges carry the ordinal on the first
2681
+ // term and cardinal on the rest (the normal pt-BR pattern).
2682
+ function dateWords(segments: Segment[]): string[] {
2683
+ return segments.flatMap(function word(segment) {
2684
+ if (segment.kind === 'range') {
2685
+ return [dayOrdinal(segment.bounds[0]) + ' a ' + segment.bounds[1]];
2686
+ }
2687
+
2688
+ if (segment.kind === 'step') {
2689
+ return segment.fires.map(function fire(value, index) {
2690
+ return index === 0 ? dayOrdinal(value) : '' + value;
2691
+ });
2692
+ }
2693
+
2694
+ return [dayOrdinal(segment.value)];
2695
+ });
2696
+ }
2697
+
2698
+ // The day-of-month value as words: the 1st is the ordinal "1º" (a deep pt-BR
2699
+ // norm — calendars, official texts, speech); every other day stays cardinal.
2700
+ function dayOrdinal(value: NameToken): string {
2701
+ return +value === 1 ? '1º' : '' + value;
2702
+ }
2703
+
2704
+ // Numeric fire values as digits.
2705
+ function wordList(fires: number[]): string[] {
2706
+ return fires.map(function digit(value) {
2707
+ return '' + value;
2708
+ });
2709
+ }
2710
+
2711
+ // Join a list with commas and a terminal "e". Portuguese never takes a comma
2712
+ // before "e" in a simple series (the es donor's RAE coma ante "y" is dropped).
2713
+ function joinList(items: string[]): string {
2714
+ if (items.length <= 1) {
2715
+ return items.join('');
2716
+ }
2717
+
2718
+ if (items.length === 2) {
2719
+ return items[0] + ' e ' + items[1];
2720
+ }
2721
+
2722
+ return items.slice(0, -1).join(', ') + ' e ' + items[items.length - 1];
2723
+ }
2724
+
2725
+ // Spell the integers zero through ten ("a cada cinco minutos"); digits
2726
+ // otherwise, and always with `short`. Masculine by default (minutos, segundos,
2727
+ // dias, anos).
2728
+ function numero(n: number, opts: Opts): string | number {
2729
+ return numeral(n, numeros, opts);
2730
+ }
2731
+
2732
+ // The feminine spelling for a count of feminine nouns ("a cada duas horas"):
2733
+ // "dois" -> "duas" is the only gendered cardinal in the 0-10 set pt spells.
2734
+ function numeroF(n: number, opts: Opts): string | number {
2735
+ const word = numero(n, opts);
2736
+
2737
+ return word === 'dois' ? 'duas' : word;
2738
+ }
2739
+
2740
+ // The canonical weekday number (Sunday=0) from a cron name or Quartz stem
2741
+ // (`5L`, `MON#2`), folding the Sunday alias 7 to 0.
2742
+ function canonicalWeekday(token: NameToken): number {
2743
+ const number = toFieldNumber('' + token, weekdayNumbers);
2744
+
2745
+ return number === 7 ? 0 : number;
2746
+ }
2747
+
2748
+ // A weekday name (with "-feira" for Mon-Fri) from a canonical number or Quartz
2749
+ // stem.
2750
+ function weekdayName(token: NameToken): string {
2751
+ return weekdayNames[canonicalWeekday(token)];
2752
+ }
2753
+
2754
+ // A weekday bare stem (no "-feira") from a canonical number or Quartz stem,
2755
+ // for list/range suffix-ellipsis.
2756
+ function weekdayStem(token: NameToken): string {
2757
+ return weekdayStems[canonicalWeekday(token)];
2758
+ }
2759
+
2760
+ // The plural weekday form (with "-feira"): the -feira days are invariant in
2761
+ // the stem and pluralize the "feira" element ("segundas-feiras"); sábado and
2762
+ // domingo take -s ("sábados", "domingos").
2763
+ function pluralWeekday(token: NameToken): string {
2764
+ return pluralFeira(canonicalWeekday(token));
2765
+ }
2766
+
2767
+ // The plural full form for a weekday number: "segundas-feiras" for the -feira
2768
+ // days, "domingos"/"sábados" otherwise.
2769
+ function pluralFeira(number: number): string {
2770
+ if (weekdayFeminine(number)) {
2771
+ return pluralStem(number) + '-feiras';
2772
+ }
2773
+
2774
+ return weekdayNames[number] + 's';
2775
+ }
2776
+
2777
+ // The plural bare stem for a -feira weekday ("segundas", "quartas"): the stem
2778
+ // pluralizes (all -feira stems end in -a), the dropped "-feira" is supplied (or
2779
+ // elided) by the caller.
2780
+ function pluralStem(number: number): string {
2781
+ return weekdayStems[number] + 's';
2782
+ }
2783
+
2784
+ // A month name from a canonical month number. The name array has a leading
2785
+ // null hole for the 1-based index.
2786
+ function monthName(token: NameToken): string {
2787
+ return monthNames[+token] as string;
2788
+ }
2789
+
2790
+
2791
+ // The Portuguese language module: the Schedule renderer plus the language-owned
2792
+ // strings and option normalization.
2793
+ const pt: Language<PortugueseStyle> = {
2794
+ describe,
2795
+ fallback: 'um padrão cron irreconhecível',
2796
+ options: normalizeOptions,
2797
+ reboot: 'ao iniciar o sistema',
2798
+ // A description ending in a period already carries it, so closing the
2799
+ // sentence must not double it.
2800
+ sentence: (description) =>
2801
+ 'Se executa ' + description + (description.endsWith('.') ? '' : '.')
2802
+ };
2803
+
2804
+ export default pt;