cronli5 0.1.5 → 0.1.7

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.
@@ -4,7 +4,9 @@
4
4
  // big-endian dates, 每 for recurrence, 24-hour clock with 凌晨0点/正午 anchors,
5
5
  // day periods under `ampm`. The style contract is src/lang/zh/notes.md.
6
6
 
7
- import {arithmeticStep, toFieldNumber} from '../../core/util.js';
7
+ import {
8
+ arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
9
+ } from '../../core/util.js';
8
10
  import {maxClockTimes, monthNumbers, weekdayNumbers} from '../../core/specs.js';
9
11
  import type {Cronli5Options} from '../../types.js';
10
12
  import type {
@@ -314,9 +316,30 @@ function renderMinutePast(ir: IR): string {
314
316
  return minuteHourClause(ir);
315
317
  }
316
318
 
317
- // The hour list as clock words: "9点、11点和13点".
319
+ // One hour segment as clock words by its form: a range is a span ("9点至20点"),
320
+ // a single is one clock word ("22点"), a step keeps its fires enumerated as
321
+ // clock words ("9点、11点、13点"). A range stated as a list element should read
322
+ // as the span the source wrote, not the hours it expands to — the same choice
323
+ // en/es/de/fi make ("from 9 a.m. through 8 p.m. and at 10 p.m.").
324
+ function hourSegmentWords(segment: Segment): string[] {
325
+ if (segment.kind === 'range') {
326
+ return [hourWord(+segment.bounds[0]) + '至' + hourWord(+segment.bounds[1])];
327
+ }
328
+
329
+ if (segment.kind === 'step') {
330
+ return segment.fires.map(hourWord);
331
+ }
332
+
333
+ return [hourWord(+segment.value)];
334
+ }
335
+
336
+ // The hour field as clock words, by segment form: "9点、11点和13点" for a list
337
+ // of singles, "9点至20点和22点" for a range plus a single. Each segment renders
338
+ // as the operator the source wrote (range → span), not its expanded fires.
318
339
  function hourList(ir: IR): string {
319
- return joinAnd(hourFires(ir).map(hourWord));
340
+ const words = fieldSegments(ir, 'hour').flatMap(hourSegmentWords);
341
+
342
+ return joinAnd(words);
320
343
  }
321
344
 
322
345
  // A frame that confines a cadence to active hours: a range gives "在F点至T点之
@@ -340,14 +363,17 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
340
363
  const base = stepClause(minuteStep, UNITS.minute, '分', '每小时');
341
364
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
342
365
 
343
- if (hours.kind === 'step') {
344
- const hourStep = stepSegment(ir, 'hour');
366
+ // An hour stride (a clean step, or an offset/non-tiling progression the core
367
+ // kept a step shape or enumerated to a list) leads the minute cadence:
368
+ // "每2小时每5分钟", "从2点起每6小时每15分钟". A clean cadence concatenates as
369
+ // before; a bounded cadence ends on "至K点", so a comma keeps that endpoint
370
+ // from gluing onto the minute clause ("从9点起每2小时,至17点,每2分钟").
371
+ if (hours.kind === 'step' || hours.kind === 'during') {
372
+ const hourCad = hourCadencePhrase(ir);
345
373
 
346
- // "每N小时" is only faithful from midnight; an offset step (2/6 fires at
347
- // 2,8,14,20) enumerates its hours instead.
348
- return hourStep.startToken === '*' ?
349
- cadence(hourStep.interval, UNITS.hour) + base :
350
- '在' + hourList(ir) + ',' + base;
374
+ if (hourCad !== null) {
375
+ return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + base;
376
+ }
351
377
  }
352
378
 
353
379
  if (hours.kind === 'single' ||
@@ -383,15 +409,20 @@ function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
383
409
  }
384
410
 
385
411
  // A minute clause across discrete hours. A wildcard minute reads "在9点、11点…,
386
- // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟".
412
+ // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟". An
413
+ // hour progression reads as its cadence ("从9点起每2小时,至17点,每分钟") rather
414
+ // than the enumerated hours, the same idiom the minute field uses.
387
415
  function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
388
416
  const {form} = plan as Extract<PlanNode, {kind: 'minutesAcrossHours'}>;
417
+ const hourCad = hourCadencePhrase(ir);
389
418
 
390
419
  if (form === 'wildcard') {
391
- return '在' + hourList(ir) + ',每分钟';
420
+ return hourCad === null ?
421
+ '在' + hourList(ir) + ',每分钟' :
422
+ hourCad + ',每分钟';
392
423
  }
393
424
 
394
- return hourList(ir) + ',' + minuteHourClause(ir) + ',每分钟';
425
+ return (hourCad ?? hourList(ir)) + ',' + minuteHourClause(ir) + ',每分钟';
395
426
  }
396
427
 
397
428
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
@@ -400,22 +431,22 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
400
431
  const hourStep = stepSegment(ir, 'hour');
401
432
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
402
433
 
403
- // A minute list enumerates its hours, with no "from M" idiom to lean on:
404
- // "每小时5分和30分,在1点、3点…".
434
+ // A minute list reads as the hour cadence plus the minute list ("每2小时,
435
+ // 每小时0、25、50分"; offset "从1点起每2小时,每小时5分和30"), the same compaction
436
+ // the wildcard/range minute already uses, rather than the enumerated hours.
405
437
  if (form === 'list') {
406
- return renderMinutePast(ir) + ',在' + hourList(ir);
438
+ return hourCadencePhrase(ir) + '' + renderMinutePast(ir);
407
439
  }
408
440
 
409
441
  const minuteTail = form === 'wildcard' ?
410
442
  '每分钟' :
411
443
  minuteHourClause(ir) + ',每分钟';
412
444
 
413
- // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
414
- // discrete list; "每N小时" is faithful only from midnight.
445
+ // An offset or non-tiling stride (2/6 fires at 2,8,14,20) reads as its
446
+ // cadence ("从2点起每6小时"). A wildcard minute hangs off it with a comma; a
447
+ // named minute follows the cadence and its own comma.
415
448
  if (hourStep.startToken !== '*') {
416
- return form === 'wildcard' ?
417
- '在' + hourList(ir) + ',' + minuteTail :
418
- hourList(ir) + ',' + minuteTail;
449
+ return hourCadencePhrase(ir) + '' + minuteTail;
419
450
  }
420
451
 
421
452
  // A step-2 hour from midnight IS exactly the even hours; name them so, rather
@@ -435,12 +466,10 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
435
466
  function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
436
467
  // An hour step (or arithmetic-progression hour list) under a single pinned
437
468
  // minute reads as a cadence rather than a cross-product of clock times.
438
- if (ir.shapes.minute === 'single') {
439
- const cad = hourCadence(ir);
469
+ const cad = hourCadenceText(ir);
440
470
 
441
- if (cad !== null) {
442
- return cad;
443
- }
471
+ if (cad !== null) {
472
+ return cad;
444
473
  }
445
474
 
446
475
  const {times} = plan as Extract<PlanNode, {kind: 'clockTimes'}>;
@@ -454,20 +483,34 @@ function renderCompactClockTimes(ir: IR, plan: PlanNode): string {
454
483
  // An hour step (or arithmetic-progression hour list) under the single pinned
455
484
  // minute reads as a cadence, not a wall of clock times. (Returns null for an
456
485
  // irregular list or a range, which keep enumerating below.)
457
- if (ir.shapes.minute === 'single') {
458
- const cad = hourCadence(ir);
486
+ const cad = hourCadenceText(ir);
459
487
 
460
- if (cad !== null) {
461
- return cad;
462
- }
488
+ if (cad !== null) {
489
+ return cad;
463
490
  }
464
491
 
465
- const {minute} = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
492
+ const compact = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
466
493
  const secs = fieldSegments(ir, 'second');
467
494
  const tail = secs.length && ir.pattern.second !== '0' ?
468
495
  ',第' + valueText(secs) + '秒' : '';
469
496
 
470
- if (minute > 0) {
497
+ // A multi-valued minute (`fold` false) names its whole set, never just its
498
+ // first fire — a list starting at 0 ("*/25" -> :00,:25,:50) must keep the
499
+ // minute clause, not drop it because the leading fire is 0. The hour reads as
500
+ // its bounded cadence when its fires form a progression ("从0点起每5小时,至20
501
+ // 点"), composed after the minute set, the same idiom the stepped-hour path
502
+ // uses; an irregular hour list keeps enumerating with the "在…" frame.
503
+ if (!compact.fold) {
504
+ const hourCad = hourCadencePhrase(ir);
505
+
506
+ return hourCad === null ?
507
+ minuteHourClause(ir) + ',在' + hourList(ir) + tail :
508
+ hourCad + ',' + minuteHourClause(ir) + tail;
509
+ }
510
+
511
+ // A single pinned minute past 0 leads with its clause; a pinned 0 folds into
512
+ // the hour times (the :00 is implicit).
513
+ if (compact.minute > 0) {
471
514
  return minuteHourClause(ir) + ',在' + hourList(ir) + tail;
472
515
  }
473
516
 
@@ -497,67 +540,95 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
497
540
  range.last + '分之间,每分钟';
498
541
  }
499
542
 
500
- // A stepped hour field: "每2小时", or its two fires as clock words when the
501
- // stride fires only twice. An uneven stride (one that does not divide 24) is
502
- // rewritten to its fire list upstream and never reaches here.
543
+ // A stepped hour field as its cadence: "每2小时" (clean), "从1点起每2小时"
544
+ // (offset), "从9点起每2小时,至17点" (bounded). A stride that fires only twice
545
+ // reads instead as its two clock words ("凌晨0点和正午", "8点和20点"), shorter and
546
+ // clearer than a cadence for a pair.
503
547
  function renderHourStep(ir: IR): string {
504
548
  const segment = stepSegment(ir, 'hour');
505
549
 
506
- if (segment.startToken !== '*') {
507
- return hourList(ir);
508
- }
509
-
510
- // A step that fires only twice reads as two clock times ("凌晨0点和正午").
511
550
  if (segment.fires.length <= 2) {
512
551
  return joinAnd(segment.fires.map(hourWord));
513
552
  }
514
553
 
515
- return cadence(segment.interval, UNITS.hour);
554
+ return hourCadencePhrase(ir) as string;
516
555
  }
517
556
 
518
557
  // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
519
558
 
520
- // The hour field's stride, or null when the hour is not a cadence: a clean
521
- // step segment from midnight yields its {interval, last}; an all-single hour
522
- // list yields one only when its values form a long-enough arithmetic
523
- // progression from midnight (so an irregular list like 9,17 keeps
524
- // enumerating). Chinese has no "每N小时从X起" offset-hour idiom, so an offset or
525
- // bounded stride (start > 0, or an interval not dividing 24) returns null and
526
- // the caller enumerates its clock words, as before. The IR is unchanged.
527
- function hourStride(ir: IR): {interval: number; last: number} | null {
559
+ // The hour field's stride, or null when the hour is not a cadence: a step
560
+ // segment yields its {interval, start, last}; an all-single hour list yields
561
+ // one only when its values form a long-enough arithmetic progression (so an
562
+ // irregular list, or a too-short one like 9,17, keeps enumerating). An offset
563
+ // (start > 0) or non-tiling (interval 24) stride is still a cadence — Chinese
564
+ // names its start and endpoint ("从M点起每N小时,至K点"), the same idiom the
565
+ // minute field already uses so it is no longer rejected. The IR is unchanged.
566
+ function hourStride(
567
+ ir: IR
568
+ ): {interval: number; start: number; last: number} | null {
528
569
  const segments = fieldSegments(ir, 'hour');
529
570
 
530
571
  if (segments.length === 1 && segments[0].kind === 'step') {
531
- const segment = segments[0];
532
- const start = segment.startToken === '*' ? 0 : +segment.startToken;
572
+ const {fires, interval} = segments[0];
533
573
 
534
- if (start !== 0 || 24 % segment.interval !== 0) {
535
- return null;
536
- }
537
-
538
- return {interval: segment.interval,
539
- last: segment.fires[segment.fires.length - 1]};
574
+ return {interval, start: fires[0], last: fires[fires.length - 1]};
540
575
  }
541
576
 
542
577
  const values = singleValues(segments);
543
- const step = values && arithmeticStep(values);
544
578
 
545
- if (!step || step.start !== 0 || 24 % step.interval !== 0) {
546
- return null;
579
+ return values && arithmeticStep(values);
580
+ }
581
+
582
+ // The hour field's cadence phrase ("每2小时", "从1点起每2小时", "从0点起每5小时,
583
+ // 至20点"), or null when the hour is not a single arithmetic progression (an
584
+ // irregular list, a range, or a too-short list keeps enumerating). The 24-cycle
585
+ // analog of strideFromSegments — it routes the stride through the one phrasing
586
+ // renderStride speaks, so a clean, offset, or non-tiling hour stride all read
587
+ // as the cadence the equivalent minute step does.
588
+ function hourCadencePhrase(ir: IR): string | null {
589
+ const stride = hourStride(ir);
590
+
591
+ return stride && renderStride({
592
+ ...stride, cycle: 24, unit: UNITS.hour, mark: '点', anchor: ''
593
+ });
594
+ }
595
+
596
+ // A wildcard or sub-minute step second confined to minute 0 of an hour stride
597
+ // is a confinement, not a juxtaposed cadence. The even-hour stride (interval 2
598
+ // from midnight) reuses the even-hours idiom ("在偶数小时0分的每一秒") so the form
599
+ // does NOT contain the bare "每2小时" and can never be misread as the absorbing
600
+ // hour cadence (the same reason en says "for one minute during every other
601
+ // hour", not "every two hours"). An OFFSET stride names its start ("从1点起每2小时"),
602
+ // already unambiguous — it cannot be heard as the bare cadence — so it folds
603
+ // "0分" and the second onto that named cadence ("从1点起每2小时0分的每一秒"). A bare
604
+ // cadence from midnight (no start named, e.g. "每3小时") keeps enumerating its
605
+ // hours so it is never heard as the absorbing form.
606
+ function minuteZeroConfinement(
607
+ ir: IR, stride: {interval: number; start: number}, prefix: string
608
+ ): string | null {
609
+ if (stride.interval === 2 && stride.start === 0) {
610
+ return '在偶数小时0分' + secondTail(ir);
611
+ }
612
+
613
+ if (prefix.indexOf('从') !== -1) {
614
+ return prefix + '0分' + secondTail(ir);
547
615
  }
548
616
 
549
- return {interval: step.interval, last: step.last};
617
+ return null;
550
618
  }
551
619
 
552
620
  // Render an hour step (or arithmetic-progression hour list) under a single
553
- // pinned minute and a second as a cadence — "每N小时" plus the minute/second —
554
- // instead of cross-multiplying the hours into a wall of clock times. Returns
555
- // null when the hour is not a clean stride from midnight, or when the
556
- // cross-product is short enough that enumeration is no longer than the cadence:
557
- // a meaningful second makes every clock time carry a second, so any stride is
558
- // worth compacting; otherwise the stride must exceed the clock-time cap, the
559
- // same point at which the core itself stops enumerating. Renderer-only; the IR
560
- // is unchanged.
621
+ // pinned minute and a second as a cadence — the hour cadence plus the
622
+ // minute/second — instead of cross-multiplying the hours into a wall of clock
623
+ // times. Returns null when the hour is not a stride, when the cross-product is
624
+ // short enough that enumeration is no longer than the cadence (a meaningful
625
+ // second makes every clock time carry a second, so any stride is worth
626
+ // compacting; otherwise the stride must exceed the clock-time cap, the same
627
+ // point at which the core itself stops enumerating), or when the cadence is
628
+ // bounded ("…,至K点"): a trailing minute fused onto its endpoint ("至20点0分")
629
+ // would read as a clock time, so a bounded stride keeps enumerating its fused
630
+ // clock times here, naming the cadence only where no minute follows it (the
631
+ // bare hour field). Renderer-only; the IR is unchanged.
561
632
  function hourCadence(ir: IR): string | null {
562
633
  const stride = hourStride(ir);
563
634
 
@@ -565,25 +636,24 @@ function hourCadence(ir: IR): string | null {
565
636
  return null;
566
637
  }
567
638
 
568
- const fires = stride.last / stride.interval + 1;
639
+ const fires = (stride.last - stride.start) / stride.interval + 1;
569
640
 
570
641
  if (ir.pattern.second === '0' && fires <= maxClockTimes) {
571
642
  return null;
572
643
  }
573
644
 
574
- const prefix = cadence(stride.interval, UNITS.hour);
645
+ const prefix = hourCadencePhrase(ir) as string;
646
+
647
+ // A bounded cadence cannot carry a fused minute unambiguously; enumerate.
648
+ if (prefix.indexOf('至') !== -1) {
649
+ return null;
650
+ }
651
+
575
652
  const minute = +ir.pattern.minute;
576
653
  const subMinute = ir.pattern.second === '*' || ir.shapes.second === 'step';
577
654
 
578
- // A wildcard or sub-minute step second confined to minute 0 of a clean
579
- // stride is a confinement, not a juxtaposed cadence. Reuse the even-hours
580
- // idiom ("在偶数小时0分的每一秒") so the form does NOT contain the bare "每2小时"
581
- // and can never be misread as the absorbing hour cadence (the same reason en
582
- // says "for one minute during every other hour", not "every two hours"). The
583
- // idiom exists only for the even-hour stride; another clean stride keeps
584
- // enumerating (return null) rather than coin a misleading "每N小时…" form.
585
655
  if (minute === 0 && subMinute) {
586
- return stride.interval === 2 ? '在偶数小时0分' + secondTail(ir) : null;
656
+ return minuteZeroConfinement(ir, stride, prefix);
587
657
  }
588
658
 
589
659
  // A pinned minute 0 folds into the cadence with the explicit "0分" so the
@@ -599,6 +669,24 @@ function hourCadence(ir: IR): string | null {
599
669
  prefix + minute + '分' + secondTail(ir);
600
670
  }
601
671
 
672
+ // The cadence a clock-point core (clockTimes/compactClockTimes/composeSeconds)
673
+ // renders an hour stride to, or null. A bare hour stride (minute 0 on the plain
674
+ // :00 second) is the cadence phrase itself — "每2小时", "从0点起每5小时,至20点" —
675
+ // so a short non-tiling stride like */5, which hourCadence keeps enumerating
676
+ // (no minute to fold, nothing to disambiguate), still reads as the cadence. A
677
+ // pinned minute or meaningful second folds into the cadence via hourCadence.
678
+ function hourCadenceText(ir: IR): string | null {
679
+ if (ir.shapes.minute !== 'single') {
680
+ return null;
681
+ }
682
+
683
+ if (+ir.pattern.minute === 0 && ir.pattern.second === '0') {
684
+ return hourCadencePhrase(ir);
685
+ }
686
+
687
+ return hourCadence(ir);
688
+ }
689
+
602
690
  // The fused second tail for an hour cadence: "的每一秒" for a wildcard second,
603
691
  // else "的" + the second's own clause ("的第30秒", "的每15秒").
604
692
  function secondTail(ir: IR): string {
@@ -676,11 +764,13 @@ function minuteClause(ir: IR): string {
676
764
  return valueList(fieldSegments(ir, 'minute'), '分');
677
765
  }
678
766
 
679
- // Whether the hour field is a true "every N hours" cadence (vs discrete fires
680
- // like 9-17/2, whose start token is a number).
681
- function isHourCadence(ir: IR): boolean {
682
- return ir.shapes.hour === 'step' &&
683
- stepSegment(ir, 'hour').startToken === '*';
767
+ // A single second folds into each clock time a clockTimes rest renders
768
+ // ("9点5分30秒"), so it is already spoken; appending the second clause again
769
+ // would double it. A wildcard/list/range second does not fold, so it still
770
+ // leads its own clause after the clock times.
771
+ function clockRestCarriesSecond(rest: PlanNode): boolean {
772
+ return rest.kind === 'clockTimes' &&
773
+ rest.times.some((time) => Boolean(time.second));
684
774
  }
685
775
 
686
776
  // minute = 0 ("on the hour"): render the rest schedule and attach the second.
@@ -710,11 +800,10 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
710
800
  }
711
801
 
712
802
  const restText = render(ir, rest, opts);
803
+ const secTail = clockRestCarriesSecond(rest) ? '' : sec;
713
804
 
714
- if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
715
- if (isDaily(ir)) {
716
- return '每天' + restText + sec;
717
- }
805
+ if (composedClock && isDaily(ir)) {
806
+ return '每天' + restText + secTail;
718
807
  }
719
808
 
720
809
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -723,7 +812,7 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
723
812
  return restText + ',' + sec;
724
813
  }
725
814
 
726
- return restText + sec;
815
+ return restText + secTail;
727
816
  }
728
817
 
729
818
  // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
@@ -733,6 +822,15 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
733
822
  // :00, not 3,600 across the hour) stays visible. The daily frame leads with
734
823
  // 每天; a weekday or date qualifier is added by describe().
735
824
  function composeMinuteZeroClocks(ir: IR, sec: string): string {
825
+ // An hour RANGE (or a list whose segments include a range) reads as the span
826
+ // the source wrote ("9点至17点"), not the wall of clock words it expands to —
827
+ // the hour-RANGE analog of the hour-step cadence. A pure single-value list
828
+ // (9,17) has no range to span and keeps enumerating below.
829
+ if (hasHourWindow(ir)) {
830
+ return isDaily(ir) ? '每天' + hourRangeWindow(ir, sec) :
831
+ hourRangeWindow(ir, sec);
832
+ }
833
+
736
834
  const clocks = hourFires(ir).map(function clock(hour): string {
737
835
  // Noon's word (正午) already pins 12:00, so the "0分" is redundant for it;
738
836
  // midnight (凌晨0点) and other hours still need it to pin the minute.
@@ -747,13 +845,45 @@ function composeMinuteZeroClocks(ir: IR, sec: string): string {
747
845
  return isDaily(ir) ? '每天' + core : core;
748
846
  }
749
847
 
848
+ // Whether the hour field is a range — or a list whose segments include a
849
+ // range — and so forms a window ("9点至17点") rather than a wall of clock
850
+ // words. A pure single-value list (9,17) has no range to span; a step is
851
+ // handled by hourStride/hourCadence.
852
+ function hasHourWindow(ir: IR): boolean {
853
+ return fieldSegments(ir, 'hour').some(function range(segment) {
854
+ return segment.kind === 'range';
855
+ });
856
+ }
857
+
858
+ // The hour-range window under a pinned minute 0 and a meaningful or wildcard
859
+ // second: the hour span list ("9点至17点", "9点至20点和22点") plus the second.
860
+ // A wildcard or sub-minute step second pins the explicit "0分" so the
861
+ // one-minute confinement stays visible ("9点至17点0分的每一秒"), distinct from
862
+ // the bare hourly window ("在9点至17点之间,每小时"); a single/list/range second
863
+ // reads as a clock-point span with the second appended ("9点至17点,第30秒"),
864
+ // matching the folded compact form for the same shape.
865
+ function hourRangeWindow(ir: IR, sec: string): string {
866
+ const span = hourList(ir);
867
+
868
+ if (ir.pattern.second === '*' || ir.shapes.second === 'step') {
869
+ return span + '0分' + (sec === '每秒' ? '的每一秒' : '的' + sec);
870
+ }
871
+
872
+ return span + ',' + sec;
873
+ }
874
+
750
875
  // Wildcard or stepped minute: hang the "每分钟/每N分钟每秒" tail off the hour.
751
876
  function composeSecondsCadence(ir: IR): string {
752
877
  const sec = secondClause(ir);
753
878
  const tail = minuteClause(ir) + sec;
754
879
 
755
- if (isHourCadence(ir)) {
756
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + '的' + tail;
880
+ const hourCad = hourCadencePhrase(ir);
881
+
882
+ if (hourCad !== null) {
883
+ // The cadence absorbs the tail with "的" ("每2小时的每分钟每秒",
884
+ // "从1点起每2小时的每分钟每秒"); a bounded cadence ends on "至K点", so its tail
885
+ // takes a comma to keep that endpoint from reading as a fused clock time.
886
+ return hourCad + (hourCad.indexOf('至') === -1 ? '的' : ',') + tail;
757
887
  }
758
888
 
759
889
  if (ir.shapes.hour === 'single') {
@@ -805,9 +935,10 @@ function composeSecondsListed(ir: IR): string {
805
935
  return minutes + ',' + sec;
806
936
  }
807
937
 
808
- if (isHourCadence(ir)) {
809
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + ',' +
810
- minutes + ',' + sec;
938
+ const hourCad = hourCadencePhrase(ir);
939
+
940
+ if (hourCad !== null) {
941
+ return hourCad + ',' + minutes + ',' + sec;
811
942
  }
812
943
 
813
944
  return hourFrame(ir) + minutes + ',' + sec;
@@ -936,7 +1067,7 @@ function quartzDate(token: string, monthPrefix: string): string {
936
1067
  return monthPrefix + '最后第' + token.slice(2) + '天';
937
1068
  }
938
1069
 
939
- return '最接近' + token.slice(0, -1) + '日的工作日';
1070
+ return monthPrefix + '最接近' + token.slice(0, -1) + '日的工作日';
940
1071
  }
941
1072
 
942
1073
  // The date side of a qualifier (month folded in): "每月1日", "1月1日",
@@ -957,7 +1088,17 @@ function datePhrase(ir: IR): string {
957
1088
  return month + cadence(stepSegment(ir, 'date').interval, '天');
958
1089
  }
959
1090
 
960
- return month ? month + dayList(ir) : '每月' + dayList(ir);
1091
+ if (!month) {
1092
+ return '每月' + dayList(ir);
1093
+ }
1094
+
1095
+ // A multi-month scope (range/list) ends in 月 and would run straight into the
1096
+ // day — "6月至8月1日" reads "8月1日" as August 1st. The comma keeps the month
1097
+ // scope distinct from the day ("6月至8月,1日"). A single month stays glued
1098
+ // ("6月1日"), which is unambiguous.
1099
+ const monthMulti = ir.shapes.month === 'range' || ir.shapes.month === 'list';
1100
+
1101
+ return month + (monthMulti ? ',' : '') + dayList(ir);
961
1102
  }
962
1103
 
963
1104
  // The date side WITHOUT its month or 每月 lead — just the day part: "1日",
@@ -1026,13 +1167,12 @@ function weekdayPhrase(
1026
1167
  return '每' + weekdayName(from) + '至' + weekdayName(to);
1027
1168
  }
1028
1169
 
1170
+ // Weekday lists display Monday-first (Sunday last); the IR stays canonical
1171
+ // (Sunday=0). The helper flattens steps into singles and orders the list.
1029
1172
  const days: number[] = [];
1030
1173
 
1031
- segs.forEach(function expand(seg) {
1032
- if (seg.kind === 'step') {
1033
- days.push(...seg.fires);
1034
- }
1035
- else if (seg.kind === 'single') {
1174
+ orderWeekdaysForDisplay(segs).forEach(function expand(seg) {
1175
+ if (seg.kind === 'single') {
1036
1176
  days.push(toFieldNumber(seg.value, weekdayNumbers));
1037
1177
  }
1038
1178
  });
@@ -1141,10 +1281,11 @@ function composeWindow(ir: IR, core: string): string {
1141
1281
  return qualifier(ir) + core;
1142
1282
  }
1143
1283
 
1144
- // Whether an hour cadence applies — a single pinned minute over a clean hour
1145
- // stride — so the clock-point plans take the cadence frame, not the daily one.
1284
+ // Whether an hour cadence applies — a single pinned minute over an hour stride
1285
+ // (clean, offset, or non-tiling) — so the clock-point plans take the cadence
1286
+ // frame, not the daily one.
1146
1287
  function hourCadenceApplies(ir: IR): boolean {
1147
- return ir.shapes.minute === 'single' && hourCadence(ir) !== null;
1288
+ return hourCadenceText(ir) !== null;
1148
1289
  }
1149
1290
 
1150
1291
  function describe(ir: IR, opts: Opts): string {
@@ -1,3 +1,4 @@
1
+ import type { Segment } from './ir.js';
1
2
  declare function includes(str: string | number, sub: string): boolean;
2
3
  declare function unique<T>(items: T[]): T[];
3
4
  declare function isNonNegativeInteger(value: string): boolean;
@@ -6,7 +7,15 @@ declare function arithmeticStep(values: number[]): {
6
7
  interval: number;
7
8
  last: number;
8
9
  } | null;
10
+ type WeekdaySegment = {
11
+ kind: 'single';
12
+ value: string;
13
+ } | {
14
+ kind: 'range';
15
+ bounds: [string, string];
16
+ };
17
+ declare function orderWeekdaysForDisplay(segments: Segment[]): WeekdaySegment[];
9
18
  declare function toFieldNumber(token: string, numberMap?: {
10
19
  [name: string]: number;
11
20
  }): number;
12
- export { arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique };
21
+ export { arithmeticStep, includes, isNonNegativeInteger, orderWeekdaysForDisplay, toFieldNumber, unique };