cronli5 0.1.4 → 0.1.5

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,8 +4,8 @@
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 {toFieldNumber} from '../../core/util.js';
8
- import {monthNumbers, weekdayNumbers} from '../../core/specs.js';
7
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
8
+ import {maxClockTimes, monthNumbers, weekdayNumbers} from '../../core/specs.js';
9
9
  import type {Cronli5Options} from '../../types.js';
10
10
  import type {
11
11
  Field, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -43,6 +43,108 @@ function cadence(interval: number, unit: string): string {
43
43
  return interval === 1 ? '每' + unit : '每' + interval + unit;
44
44
  }
45
45
 
46
+ // A step cadence to phrase over a `cycle`-long field (60 for minute/second),
47
+ // running from `start` to `last`. `unit` is the cadence noun ("分钟"/"秒"),
48
+ // `mark` the bound measure word ("分"/"秒"), `anchor` the larger frame
49
+ // ("每小时"/"每分钟").
50
+ interface Stride {
51
+ interval: number;
52
+ start: number;
53
+ last: number;
54
+ cycle: number;
55
+ unit: string;
56
+ mark: string;
57
+ anchor: string;
58
+ }
59
+
60
+ // Speak a step cadence over a `cycle`-long field. A clean stride from the top
61
+ // of the cycle is the bare cadence ("每2分钟"); a uniform offset (start within
62
+ // the first interval, the interval still dividing the cycle) names only its
63
+ // start, since it wraps cleanly with no distinct endpoint ("每小时从5分起每6分
64
+ // 钟"); a non-uniform stride (start >= interval, or an interval that does not
65
+ // divide the cycle) pins both endpoints so the bounded, non-wrapping set reads
66
+ // unambiguously ("每小时从3分起每2分钟,至59分"). This is the one phrasing for
67
+ // every step the renderer speaks, whether the core kept it a step shape (a
68
+ // clean cadence) or enumerated it to a fire list (an offset/uneven set the
69
+ // list path recognizes as a progression).
70
+ function renderStride(stride: Stride): string {
71
+ const {interval, start, last, cycle, unit, mark, anchor} = stride;
72
+ const tiles = cycle % interval === 0;
73
+
74
+ if (start === 0 && tiles) {
75
+ return cadence(interval, unit);
76
+ }
77
+
78
+ const lead = anchor + '从' + start + mark + '起' + cadence(interval, unit);
79
+
80
+ return start < interval && tiles ? lead : lead + ',至' + last + mark;
81
+ }
82
+
83
+ // The sorted numeric values a field's segments cover, or null if any segment
84
+ // is not a discrete single (a range or sub-step is not a plain fire list).
85
+ function singleValues(segments: Segment[]): number[] | null {
86
+ const values: number[] = [];
87
+
88
+ for (const segment of segments) {
89
+ if (segment.kind !== 'single') {
90
+ return null;
91
+ }
92
+
93
+ values.push(+segment.value);
94
+ }
95
+
96
+ return values;
97
+ }
98
+
99
+ // Speak a minute/second field's enumerated fires as a step cadence when they
100
+ // form an arithmetic progression long enough to beat the list (the core
101
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
102
+ // the renderer recognizes the progression). Returns null for a non-progression
103
+ // or a too-short list, leaving the caller to enumerate.
104
+ function strideFromSegments(
105
+ segments: Segment[],
106
+ unit: string,
107
+ mark: string,
108
+ anchor: string
109
+ ): string | null {
110
+ const values = singleValues(segments);
111
+ const step = values && arithmeticStep(values);
112
+
113
+ return step ?
114
+ renderStride({...step, cycle: 60, unit, mark, anchor}) :
115
+ null;
116
+ }
117
+
118
+ // A step *shape* segment as its cadence ("每小时从5分起每6分钟"). A bounded
119
+ // sub-range step (`a-b/n`) is not a whole-cycle stride, so it lists its fires;
120
+ // a short offset cadence (three fires or fewer) also lists, since the list is
121
+ // no longer than the cadence. Everything else routes through `renderStride`.
122
+ // The uneven whole-cycle step never reaches here as a step shape — the core
123
+ // enumerates it to a fire list, which the list path recognizes instead.
124
+ function stepClause(
125
+ segment: StepSegment,
126
+ unit: string,
127
+ mark: string,
128
+ anchor: string
129
+ ): string {
130
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
131
+ const short = start !== 0 && segment.fires.length <= 3;
132
+
133
+ if (segment.startToken.indexOf('-') !== -1 || short) {
134
+ return anchor + segment.fires.join('、') + mark;
135
+ }
136
+
137
+ return renderStride({
138
+ interval: segment.interval,
139
+ start,
140
+ last: segment.fires[segment.fires.length - 1],
141
+ cycle: 60,
142
+ unit,
143
+ mark,
144
+ anchor
145
+ });
146
+ }
147
+
46
148
  // The day period a 12-hour clock prepends under `ampm` (notes.md boundaries).
47
149
  function dayPeriod(hour: number): string {
48
150
  if (hour < 6) {
@@ -193,8 +295,23 @@ function renderEveryHour(): string {
193
295
  }
194
296
 
195
297
  // A minute anchored to the hour: "每小时1分", "每小时5分和30分", "每小时0至30分".
298
+ // A regular step reads as a stride cadence ("每小时从3分起每2分钟,至59分"),
299
+ // whether the core kept it a step shape (a uniform offset divides 60) or
300
+ // enumerated it to a fire list (an offset/uneven set) — both route through the
301
+ // stride; a short or irregular set keeps the enumerated "每小时…分" list.
302
+ function minuteHourClause(ir: IR): string {
303
+ const segments = fieldSegments(ir, 'minute');
304
+
305
+ if (ir.shapes.minute === 'step') {
306
+ return stepClause(stepSegment(ir, 'minute'), '分钟', '分', '每小时');
307
+ }
308
+
309
+ return strideFromSegments(segments, '分钟', '分', '每小时') ??
310
+ '每小时' + valueList(segments, '分');
311
+ }
312
+
196
313
  function renderMinutePast(ir: IR): string {
197
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
314
+ return minuteHourClause(ir);
198
315
  }
199
316
 
200
317
  // The hour list as clock words: "9点、11点和13点".
@@ -218,11 +335,9 @@ function hourFrame(ir: IR): string {
218
335
  // A repeating minute step, optionally confined to active hours.
219
336
  function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
220
337
  const minuteStep = stepSegment(ir, 'minute');
221
- // A "每N分钟" cadence is only faithful from the top of the hour; an offset
222
- // step (5/6 fires at :05,:11,…) enumerates its fires instead.
223
- const base = minuteStep.startToken === '*' ?
224
- cadence(minuteStep.interval, UNITS.minute) :
225
- renderMinutePast(ir);
338
+ // A clean stride is the bare "每N分钟" cadence; an offset step keeps its start
339
+ // ("每小时从5分起每6分钟"). A short offset cadence still lists its fires.
340
+ const base = stepClause(minuteStep, UNITS.minute, '分', '每小时');
226
341
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
227
342
 
228
343
  if (hours.kind === 'step') {
@@ -276,8 +391,7 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
276
391
  return '在' + hourList(ir) + ',每分钟';
277
392
  }
278
393
 
279
- return hourList(ir) + ',每小时' +
280
- valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
394
+ return hourList(ir) + '' + minuteHourClause(ir) + ',每分钟';
281
395
  }
282
396
 
283
397
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
@@ -285,9 +399,16 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
285
399
  function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
286
400
  const hourStep = stepSegment(ir, 'hour');
287
401
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
402
+
403
+ // A minute list enumerates its hours, with no "from M" idiom to lean on:
404
+ // "每小时5分和30分,在1点、3点…".
405
+ if (form === 'list') {
406
+ return renderMinutePast(ir) + ',在' + hourList(ir);
407
+ }
408
+
288
409
  const minuteTail = form === 'wildcard' ?
289
410
  '每分钟' :
290
- '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
411
+ minuteHourClause(ir) + ',每分钟';
291
412
 
292
413
  // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
293
414
  // discrete list; "每N小时" is faithful only from midnight.
@@ -312,6 +433,16 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
312
433
 
313
434
  // Discrete clock times: "9点", "9点和17点".
314
435
  function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
436
+ // An hour step (or arithmetic-progression hour list) under a single pinned
437
+ // minute reads as a cadence rather than a cross-product of clock times.
438
+ if (ir.shapes.minute === 'single') {
439
+ const cad = hourCadence(ir);
440
+
441
+ if (cad !== null) {
442
+ return cad;
443
+ }
444
+ }
445
+
315
446
  const {times} = plan as Extract<PlanNode, {kind: 'clockTimes'}>;
316
447
 
317
448
  return joinAnd(times.map((t) => clockTime(t.hour, t.minute, t.second, opts)));
@@ -320,14 +451,24 @@ function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
320
451
  // Compact clock times past the cap: the hour list (the minute is folded in),
321
452
  // with a fixed second appended ("…,第30秒").
322
453
  function renderCompactClockTimes(ir: IR, plan: PlanNode): string {
454
+ // An hour step (or arithmetic-progression hour list) under the single pinned
455
+ // minute reads as a cadence, not a wall of clock times. (Returns null for an
456
+ // irregular list or a range, which keep enumerating below.)
457
+ if (ir.shapes.minute === 'single') {
458
+ const cad = hourCadence(ir);
459
+
460
+ if (cad !== null) {
461
+ return cad;
462
+ }
463
+ }
464
+
323
465
  const {minute} = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
324
466
  const secs = fieldSegments(ir, 'second');
325
467
  const tail = secs.length && ir.pattern.second !== '0' ?
326
468
  ',第' + valueText(secs) + '秒' : '';
327
469
 
328
470
  if (minute > 0) {
329
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
330
- ',在' + hourList(ir) + tail;
471
+ return minuteHourClause(ir) + ',在' + hourList(ir) + tail;
331
472
  }
332
473
 
333
474
  return hourList(ir) + tail;
@@ -340,7 +481,7 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
340
481
  if (range.minuteForm === 'lead') {
341
482
  const minuteSegs = fieldSegments(ir, 'minute');
342
483
  const past = minuteSegs.length && ir.pattern.minute !== '0' ?
343
- '每小时' + valueList(minuteSegs, '分') : '每小时';
484
+ minuteHourClause(ir) : '每小时';
344
485
 
345
486
  return '在' + hourWord(range.from) + '至' + hourWord(range.to) +
346
487
  '之间,' + past;
@@ -374,9 +515,101 @@ function renderHourStep(ir: IR): string {
374
515
  return cadence(segment.interval, UNITS.hour);
375
516
  }
376
517
 
518
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
519
+
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 {
528
+ const segments = fieldSegments(ir, 'hour');
529
+
530
+ if (segments.length === 1 && segments[0].kind === 'step') {
531
+ const segment = segments[0];
532
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
533
+
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]};
540
+ }
541
+
542
+ const values = singleValues(segments);
543
+ const step = values && arithmeticStep(values);
544
+
545
+ if (!step || step.start !== 0 || 24 % step.interval !== 0) {
546
+ return null;
547
+ }
548
+
549
+ return {interval: step.interval, last: step.last};
550
+ }
551
+
552
+ // 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.
561
+ function hourCadence(ir: IR): string | null {
562
+ const stride = hourStride(ir);
563
+
564
+ if (!stride) {
565
+ return null;
566
+ }
567
+
568
+ const fires = stride.last / stride.interval + 1;
569
+
570
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
571
+ return null;
572
+ }
573
+
574
+ const prefix = cadence(stride.interval, UNITS.hour);
575
+ const minute = +ir.pattern.minute;
576
+ const subMinute = ir.pattern.second === '*' || ir.shapes.second === 'step';
577
+
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
+ if (minute === 0 && subMinute) {
586
+ return stride.interval === 2 ? '在偶数小时0分' + secondTail(ir) : null;
587
+ }
588
+
589
+ // A pinned minute 0 folds into the cadence with the explicit "0分" so the
590
+ // confinement stays visible ("每2小时0分的第30秒"); a non-zero minute is a real
591
+ // clock minute named after the cadence ("每2小时5分的每一秒"). A plain :00
592
+ // second adds nothing.
593
+ if (minute === 0) {
594
+ return prefix + '0分' + secondTail(ir);
595
+ }
596
+
597
+ return ir.pattern.second === '0' ?
598
+ prefix + minute + '分' :
599
+ prefix + minute + '分' + secondTail(ir);
600
+ }
601
+
602
+ // The fused second tail for an hour cadence: "的每一秒" for a wildcard second,
603
+ // else "的" + the second's own clause ("的第30秒", "的每15秒").
604
+ function secondTail(ir: IR): string {
605
+ const sec = secondClause(ir);
606
+
607
+ return sec === '每秒' ? '的每一秒' : '的' + sec;
608
+ }
609
+
377
610
  // A continuous minute range fires every minute within it: "每小时0至30分,每分钟".
378
611
  function renderRangeOfMinutes(ir: IR): string {
379
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
612
+ return minuteHourClause(ir) + ',每分钟';
380
613
  }
381
614
 
382
615
  // A standalone second field: "每7秒" (step cadence) or "每分钟第4、17、42秒".
@@ -388,7 +621,8 @@ function renderStandaloneSeconds(ir: IR): string {
388
621
  return cadence(first.interval, UNITS.second);
389
622
  }
390
623
 
391
- return '每分钟第' + valueText(segs) + '秒';
624
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
625
+ '每分钟第' + valueText(segs) + '秒';
392
626
  }
393
627
 
394
628
  // A second anchored to the minute: "每分钟第1秒", "每分钟第4、17、42秒".
@@ -423,7 +657,10 @@ function secondClause(ir: IR): string {
423
657
  return cadence(first.interval, UNITS.second);
424
658
  }
425
659
 
426
- return '第' + valueText(segs) + '秒';
660
+ // An offset/uneven step the core enumerated to this list reads as a stride
661
+ // cadence when the fires form a long-enough progression.
662
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
663
+ '第' + valueText(segs) + '秒';
427
664
  }
428
665
 
429
666
  // The minute clause for a composed (seconds) schedule.
@@ -450,22 +687,26 @@ function isHourCadence(ir: IR): boolean {
450
687
  function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
451
688
  const sec = secondClause(ir);
452
689
  const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
690
+ const composedClock =
691
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
453
692
 
454
- // The minute is pinned to 0 under a specific hour: a bare clock word ("9点")
455
- // would hide the :00 and leave the second dangling ("…9点每秒"), reading as
456
- // the whole hour. Fuse the seconds with the explicit clock minute ("9点0分
457
- // 的每一秒"), so the one-minute confinement (60 fires in :00, not 3,600
458
- // across the hour) stays visible. The daily frame leads with 每天; a weekday
459
- // or date qualifier is added by describe().
460
- if ((rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') &&
461
- ir.pattern.minute === '0') {
462
- const clocks = hourFires(ir).map(function clock(hour): string {
463
- return hourWord(hour) + '0分';
464
- });
465
- const tail = sec === '每秒' ? '的每一秒' : '的' + sec;
466
- const core = joinAnd(clocks) + tail;
693
+ // An hour step (or arithmetic-progression hour list) under a single pinned
694
+ // minute reads as a cadence, not a wall of clock times ("每2小时0分的第30秒").
695
+ // hourCadence folds in the second itself, so it is returned whole, never the
696
+ // rest-plus-second fall-through that would double the second clause. The
697
+ // cadence is sub-daily (no 每天); a qualifier is added by describe().
698
+ if (composedClock) {
699
+ const hourCad = hourCadence(ir);
467
700
 
468
- return isDaily(ir) ? '每天' + core : core;
701
+ if (hourCad !== null) {
702
+ return hourCad;
703
+ }
704
+ }
705
+
706
+ // The minute is pinned to 0 under specific hours that did not compact to a
707
+ // cadence: fuse the seconds with each explicit clock minute.
708
+ if (composedClock && ir.pattern.minute === '0') {
709
+ return composeMinuteZeroClocks(ir, sec);
469
710
  }
470
711
 
471
712
  const restText = render(ir, rest, opts);
@@ -485,6 +726,27 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
485
726
  return restText + sec;
486
727
  }
487
728
 
729
+ // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
730
+ // bare clock word ("9点") would hide the :00 and leave the second dangling
731
+ // ("…9点每秒"), reading as the whole hour. Fuse the seconds with the explicit
732
+ // clock minute ("9点0分的每一秒"), so the one-minute confinement (60 fires in
733
+ // :00, not 3,600 across the hour) stays visible. The daily frame leads with
734
+ // 每天; a weekday or date qualifier is added by describe().
735
+ function composeMinuteZeroClocks(ir: IR, sec: string): string {
736
+ const clocks = hourFires(ir).map(function clock(hour): string {
737
+ // Noon's word (正午) already pins 12:00, so the "0分" is redundant for it;
738
+ // midnight (凌晨0点) and other hours still need it to pin the minute.
739
+ return hour === 12 ? '正午' : hourWord(hour) + '0分';
740
+ });
741
+ // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
742
+ // single minute, not every minute), so the stride here drops it.
743
+ const nested = strideFromSegments(fieldSegments(ir, 'second'), '秒', '秒', '');
744
+ const tail = sec === '每秒' ? '的每一秒' : '的' + (nested ?? sec);
745
+ const core = joinAnd(clocks) + tail;
746
+
747
+ return isDaily(ir) ? '每天' + core : core;
748
+ }
749
+
488
750
  // Wildcard or stepped minute: hang the "每分钟/每N分钟每秒" tail off the hour.
489
751
  function composeSecondsCadence(ir: IR): string {
490
752
  const sec = secondClause(ir);
@@ -499,6 +761,18 @@ function composeSecondsCadence(ir: IR): string {
499
761
  }
500
762
 
501
763
  if (ir.shapes.hour === 'wildcard') {
764
+ // "每秒,每2分钟" juxtaposes two cadences that read as contradictory. A
765
+ // step-2 minute from the top of the hour IS exactly the even minutes; bind
766
+ // the every-second cadence to them ("每偶数分钟的每一秒") rather than listing
767
+ // the two side by side. Other strides keep the juxtaposed form.
768
+ if (ir.shapes.minute === 'step' && sec === '每秒') {
769
+ const minuteStep = stepSegment(ir, 'minute');
770
+
771
+ if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
772
+ return '每偶数分钟的每一秒';
773
+ }
774
+ }
775
+
502
776
  return sec + ',' + minuteClause(ir);
503
777
  }
504
778
 
@@ -511,14 +785,20 @@ function composeSecondsCadence(ir: IR): string {
511
785
  // last fire onto the window end ("…17点30分") and reading as a continuous span.
512
786
  function composeSecondsListed(ir: IR): string {
513
787
  const sec = secondClause(ir);
514
- const minutes = '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
788
+ const minutes = minuteHourClause(ir);
515
789
 
516
790
  // A single restricted hour with an every-second cadence fuses the clock time
517
791
  // with its minutes — "凌晨0点5、20、35、50分的每一秒" — rather than the "每小时"
518
792
  // that falsely implies every hour. A non-wildcard second keeps the list form.
793
+ // A non-uniform minute step the core enumerated to a fire list reads as its
794
+ // stride cadence ("凌晨0点从3分起每2分钟,至59分的每一秒"); the hour fuses, so the
795
+ // stride drops its "每小时" anchor. A short or irregular set keeps the list.
519
796
  if (ir.shapes.hour === 'single' && sec === '每秒') {
520
- return hourWord(hourFires(ir)[0]) +
521
- valueList(fieldSegments(ir, 'minute'), '分') + '的每一秒';
797
+ const minuteSegs = fieldSegments(ir, 'minute');
798
+ const minuteCad = strideFromSegments(minuteSegs, '分钟', '分', '') ??
799
+ valueList(minuteSegs, '分');
800
+
801
+ return hourWord(hourFires(ir)[0]) + minuteCad + '的每一秒';
522
802
  }
523
803
 
524
804
  if (ir.shapes.hour === 'wildcard') {
@@ -861,14 +1141,26 @@ function composeWindow(ir: IR, core: string): string {
861
1141
  return qualifier(ir) + core;
862
1142
  }
863
1143
 
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.
1146
+ function hourCadenceApplies(ir: IR): boolean {
1147
+ return ir.shapes.minute === 'single' && hourCadence(ir) !== null;
1148
+ }
1149
+
864
1150
  function describe(ir: IR, opts: Opts): string {
865
1151
  const {kind} = ir.plan;
866
1152
  const core = render(ir, ir.plan, opts);
867
1153
  let composed = core;
868
1154
 
1155
+ // An hour step (or arithmetic-progression hour list) under a single pinned
1156
+ // minute is a sub-daily cadence, not a daily clock point — it takes the
1157
+ // cadence frame (no 每天), like the bare "每2小时" form.
1158
+ if (hourCadenceApplies(ir)) {
1159
+ composed = composeCadence(ir, core);
1160
+ }
869
1161
  // A compact clock list with a minute past the hour ("每小时5分…") reads as a
870
1162
  // cadence, not a daily clock point — no 每天.
871
- if (kind === 'clockTimes' ||
1163
+ else if (kind === 'clockTimes' ||
872
1164
  kind === 'compactClockTimes' && ir.pattern.minute === '0') {
873
1165
  composed = composePoint(ir, core);
874
1166
  }
@@ -93,7 +93,7 @@ export type PlanNode = {
93
93
  times: HourTimesPlan;
94
94
  } | {
95
95
  kind: 'minuteSpanAcrossHourStep';
96
- form: 'wildcard' | 'range';
96
+ form: 'wildcard' | 'range' | 'list';
97
97
  } | {
98
98
  kind: 'everyHour';
99
99
  } | {
@@ -1,7 +1,12 @@
1
1
  declare function includes(str: string | number, sub: string): boolean;
2
2
  declare function unique<T>(items: T[]): T[];
3
3
  declare function isNonNegativeInteger(value: string): boolean;
4
+ declare function arithmeticStep(values: number[]): {
5
+ start: number;
6
+ interval: number;
7
+ last: number;
8
+ } | null;
4
9
  declare function toFieldNumber(token: string, numberMap?: {
5
10
  [name: string]: number;
6
11
  }): number;
7
- export { includes, isNonNegativeInteger, toFieldNumber, unique };
12
+ export { arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique };