cronli5 0.3.4 → 0.7.2

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