cronli5 0.1.5 → 0.1.6

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.
@@ -314,9 +314,30 @@ function renderMinutePast(ir: IR): string {
314
314
  return minuteHourClause(ir);
315
315
  }
316
316
 
317
- // The hour list as clock words: "9点、11点和13点".
317
+ // One hour segment as clock words by its form: a range is a span ("9点至20点"),
318
+ // a single is one clock word ("22点"), a step keeps its fires enumerated as
319
+ // clock words ("9点、11点、13点"). A range stated as a list element should read
320
+ // as the span the source wrote, not the hours it expands to — the same choice
321
+ // en/es/de/fi make ("from 9 a.m. through 8 p.m. and at 10 p.m.").
322
+ function hourSegmentWords(segment: Segment): string[] {
323
+ if (segment.kind === 'range') {
324
+ return [hourWord(+segment.bounds[0]) + '至' + hourWord(+segment.bounds[1])];
325
+ }
326
+
327
+ if (segment.kind === 'step') {
328
+ return segment.fires.map(hourWord);
329
+ }
330
+
331
+ return [hourWord(+segment.value)];
332
+ }
333
+
334
+ // The hour field as clock words, by segment form: "9点、11点和13点" for a list
335
+ // of singles, "9点至20点和22点" for a range plus a single. Each segment renders
336
+ // as the operator the source wrote (range → span), not its expanded fires.
318
337
  function hourList(ir: IR): string {
319
- return joinAnd(hourFires(ir).map(hourWord));
338
+ const words = fieldSegments(ir, 'hour').flatMap(hourSegmentWords);
339
+
340
+ return joinAnd(words);
320
341
  }
321
342
 
322
343
  // A frame that confines a cadence to active hours: a range gives "在F点至T点之
@@ -340,14 +361,17 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
340
361
  const base = stepClause(minuteStep, UNITS.minute, '分', '每小时');
341
362
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
342
363
 
343
- if (hours.kind === 'step') {
344
- const hourStep = stepSegment(ir, 'hour');
364
+ // An hour stride (a clean step, or an offset/non-tiling progression the core
365
+ // kept a step shape or enumerated to a list) leads the minute cadence:
366
+ // "每2小时每5分钟", "从2点起每6小时每15分钟". A clean cadence concatenates as
367
+ // before; a bounded cadence ends on "至K点", so a comma keeps that endpoint
368
+ // from gluing onto the minute clause ("从9点起每2小时,至17点,每2分钟").
369
+ if (hours.kind === 'step' || hours.kind === 'during') {
370
+ const hourCad = hourCadencePhrase(ir);
345
371
 
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;
372
+ if (hourCad !== null) {
373
+ return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + base;
374
+ }
351
375
  }
352
376
 
353
377
  if (hours.kind === 'single' ||
@@ -383,15 +407,20 @@ function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
383
407
  }
384
408
 
385
409
  // A minute clause across discrete hours. A wildcard minute reads "在9点、11点…,
386
- // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟".
410
+ // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟". An
411
+ // hour progression reads as its cadence ("从9点起每2小时,至17点,每分钟") rather
412
+ // than the enumerated hours, the same idiom the minute field uses.
387
413
  function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
388
414
  const {form} = plan as Extract<PlanNode, {kind: 'minutesAcrossHours'}>;
415
+ const hourCad = hourCadencePhrase(ir);
389
416
 
390
417
  if (form === 'wildcard') {
391
- return '在' + hourList(ir) + ',每分钟';
418
+ return hourCad === null ?
419
+ '在' + hourList(ir) + ',每分钟' :
420
+ hourCad + ',每分钟';
392
421
  }
393
422
 
394
- return hourList(ir) + ',' + minuteHourClause(ir) + ',每分钟';
423
+ return (hourCad ?? hourList(ir)) + ',' + minuteHourClause(ir) + ',每分钟';
395
424
  }
396
425
 
397
426
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
@@ -400,22 +429,22 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
400
429
  const hourStep = stepSegment(ir, 'hour');
401
430
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
402
431
 
403
- // A minute list enumerates its hours, with no "from M" idiom to lean on:
404
- // "每小时5分和30分,在1点、3点…".
432
+ // A minute list reads as the hour cadence plus the minute list ("每2小时,
433
+ // 每小时0、25、50分"; offset "从1点起每2小时,每小时5分和30"), the same compaction
434
+ // the wildcard/range minute already uses, rather than the enumerated hours.
405
435
  if (form === 'list') {
406
- return renderMinutePast(ir) + ',在' + hourList(ir);
436
+ return hourCadencePhrase(ir) + '' + renderMinutePast(ir);
407
437
  }
408
438
 
409
439
  const minuteTail = form === 'wildcard' ?
410
440
  '每分钟' :
411
441
  minuteHourClause(ir) + ',每分钟';
412
442
 
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.
443
+ // An offset or non-tiling stride (2/6 fires at 2,8,14,20) reads as its
444
+ // cadence ("从2点起每6小时"). A wildcard minute hangs off it with a comma; a
445
+ // named minute follows the cadence and its own comma.
415
446
  if (hourStep.startToken !== '*') {
416
- return form === 'wildcard' ?
417
- '在' + hourList(ir) + ',' + minuteTail :
418
- hourList(ir) + ',' + minuteTail;
447
+ return hourCadencePhrase(ir) + '' + minuteTail;
419
448
  }
420
449
 
421
450
  // A step-2 hour from midnight IS exactly the even hours; name them so, rather
@@ -435,12 +464,10 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
435
464
  function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
436
465
  // An hour step (or arithmetic-progression hour list) under a single pinned
437
466
  // minute reads as a cadence rather than a cross-product of clock times.
438
- if (ir.shapes.minute === 'single') {
439
- const cad = hourCadence(ir);
467
+ const cad = hourCadenceText(ir);
440
468
 
441
- if (cad !== null) {
442
- return cad;
443
- }
469
+ if (cad !== null) {
470
+ return cad;
444
471
  }
445
472
 
446
473
  const {times} = plan as Extract<PlanNode, {kind: 'clockTimes'}>;
@@ -454,20 +481,34 @@ function renderCompactClockTimes(ir: IR, plan: PlanNode): string {
454
481
  // An hour step (or arithmetic-progression hour list) under the single pinned
455
482
  // minute reads as a cadence, not a wall of clock times. (Returns null for an
456
483
  // irregular list or a range, which keep enumerating below.)
457
- if (ir.shapes.minute === 'single') {
458
- const cad = hourCadence(ir);
484
+ const cad = hourCadenceText(ir);
459
485
 
460
- if (cad !== null) {
461
- return cad;
462
- }
486
+ if (cad !== null) {
487
+ return cad;
463
488
  }
464
489
 
465
- const {minute} = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
490
+ const compact = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
466
491
  const secs = fieldSegments(ir, 'second');
467
492
  const tail = secs.length && ir.pattern.second !== '0' ?
468
493
  ',第' + valueText(secs) + '秒' : '';
469
494
 
470
- if (minute > 0) {
495
+ // A multi-valued minute (`fold` false) names its whole set, never just its
496
+ // first fire — a list starting at 0 ("*/25" -> :00,:25,:50) must keep the
497
+ // minute clause, not drop it because the leading fire is 0. The hour reads as
498
+ // its bounded cadence when its fires form a progression ("从0点起每5小时,至20
499
+ // 点"), composed after the minute set, the same idiom the stepped-hour path
500
+ // uses; an irregular hour list keeps enumerating with the "在…" frame.
501
+ if (!compact.fold) {
502
+ const hourCad = hourCadencePhrase(ir);
503
+
504
+ return hourCad === null ?
505
+ minuteHourClause(ir) + ',在' + hourList(ir) + tail :
506
+ hourCad + ',' + minuteHourClause(ir) + tail;
507
+ }
508
+
509
+ // A single pinned minute past 0 leads with its clause; a pinned 0 folds into
510
+ // the hour times (the :00 is implicit).
511
+ if (compact.minute > 0) {
471
512
  return minuteHourClause(ir) + ',在' + hourList(ir) + tail;
472
513
  }
473
514
 
@@ -497,67 +538,71 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
497
538
  range.last + '分之间,每分钟';
498
539
  }
499
540
 
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.
541
+ // A stepped hour field as its cadence: "每2小时" (clean), "从1点起每2小时"
542
+ // (offset), "从9点起每2小时,至17点" (bounded). A stride that fires only twice
543
+ // reads instead as its two clock words ("凌晨0点和正午", "8点和20点"), shorter and
544
+ // clearer than a cadence for a pair.
503
545
  function renderHourStep(ir: IR): string {
504
546
  const segment = stepSegment(ir, 'hour');
505
547
 
506
- if (segment.startToken !== '*') {
507
- return hourList(ir);
508
- }
509
-
510
- // A step that fires only twice reads as two clock times ("凌晨0点和正午").
511
548
  if (segment.fires.length <= 2) {
512
549
  return joinAnd(segment.fires.map(hourWord));
513
550
  }
514
551
 
515
- return cadence(segment.interval, UNITS.hour);
552
+ return hourCadencePhrase(ir) as string;
516
553
  }
517
554
 
518
555
  // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
519
556
 
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 {
557
+ // The hour field's stride, or null when the hour is not a cadence: a step
558
+ // segment yields its {interval, start, last}; an all-single hour list yields
559
+ // one only when its values form a long-enough arithmetic progression (so an
560
+ // irregular list, or a too-short one like 9,17, keeps enumerating). An offset
561
+ // (start > 0) or non-tiling (interval 24) stride is still a cadence — Chinese
562
+ // names its start and endpoint ("从M点起每N小时,至K点"), the same idiom the
563
+ // minute field already uses so it is no longer rejected. The IR is unchanged.
564
+ function hourStride(
565
+ ir: IR
566
+ ): {interval: number; start: number; last: number} | null {
528
567
  const segments = fieldSegments(ir, 'hour');
529
568
 
530
569
  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
- }
570
+ const {fires, interval} = segments[0];
537
571
 
538
- return {interval: segment.interval,
539
- last: segment.fires[segment.fires.length - 1]};
572
+ return {interval, start: fires[0], last: fires[fires.length - 1]};
540
573
  }
541
574
 
542
575
  const values = singleValues(segments);
543
- const step = values && arithmeticStep(values);
544
576
 
545
- if (!step || step.start !== 0 || 24 % step.interval !== 0) {
546
- return null;
547
- }
577
+ return values && arithmeticStep(values);
578
+ }
579
+
580
+ // The hour field's cadence phrase ("每2小时", "从1点起每2小时", "从0点起每5小时,
581
+ // 至20点"), or null when the hour is not a single arithmetic progression (an
582
+ // irregular list, a range, or a too-short list keeps enumerating). The 24-cycle
583
+ // analog of strideFromSegments — it routes the stride through the one phrasing
584
+ // renderStride speaks, so a clean, offset, or non-tiling hour stride all read
585
+ // as the cadence the equivalent minute step does.
586
+ function hourCadencePhrase(ir: IR): string | null {
587
+ const stride = hourStride(ir);
548
588
 
549
- return {interval: step.interval, last: step.last};
589
+ return stride && renderStride({
590
+ ...stride, cycle: 24, unit: UNITS.hour, mark: '点', anchor: ''
591
+ });
550
592
  }
551
593
 
552
594
  // 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.
595
+ // pinned minute and a second as a cadence — the hour cadence plus the
596
+ // minute/second — instead of cross-multiplying the hours into a wall of clock
597
+ // times. Returns null when the hour is not a stride, when the cross-product is
598
+ // short enough that enumeration is no longer than the cadence (a meaningful
599
+ // second makes every clock time carry a second, so any stride is worth
600
+ // compacting; otherwise the stride must exceed the clock-time cap, the same
601
+ // point at which the core itself stops enumerating), or when the cadence is
602
+ // bounded ("…,至K点"): a trailing minute fused onto its endpoint ("至20点0分")
603
+ // would read as a clock time, so a bounded stride keeps enumerating its fused
604
+ // clock times here, naming the cadence only where no minute follows it (the
605
+ // bare hour field). Renderer-only; the IR is unchanged.
561
606
  function hourCadence(ir: IR): string | null {
562
607
  const stride = hourStride(ir);
563
608
 
@@ -565,25 +610,33 @@ function hourCadence(ir: IR): string | null {
565
610
  return null;
566
611
  }
567
612
 
568
- const fires = stride.last / stride.interval + 1;
613
+ const fires = (stride.last - stride.start) / stride.interval + 1;
569
614
 
570
615
  if (ir.pattern.second === '0' && fires <= maxClockTimes) {
571
616
  return null;
572
617
  }
573
618
 
574
- const prefix = cadence(stride.interval, UNITS.hour);
619
+ const prefix = hourCadencePhrase(ir) as string;
620
+
621
+ // A bounded cadence cannot carry a fused minute unambiguously; enumerate.
622
+ if (prefix.indexOf('至') !== -1) {
623
+ return null;
624
+ }
625
+
575
626
  const minute = +ir.pattern.minute;
576
627
  const subMinute = ir.pattern.second === '*' || ir.shapes.second === 'step';
577
628
 
578
- // A wildcard or sub-minute step second confined to minute 0 of a clean
629
+ // A wildcard or sub-minute step second confined to minute 0 of the even-hour
579
630
  // stride is a confinement, not a juxtaposed cadence. Reuse the even-hours
580
631
  // idiom ("在偶数小时0分的每一秒") so the form does NOT contain the bare "每2小时"
581
632
  // and can never be misread as the absorbing hour cadence (the same reason en
582
633
  // 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.
634
+ // idiom exists only for the even-hour stride (interval 2 from midnight);
635
+ // another stride keeps enumerating (return null) rather than coin a
636
+ // misleading "…小时…" form.
585
637
  if (minute === 0 && subMinute) {
586
- return stride.interval === 2 ? '在偶数小时0分' + secondTail(ir) : null;
638
+ return stride.interval === 2 && stride.start === 0 ?
639
+ '在偶数小时0分' + secondTail(ir) : null;
587
640
  }
588
641
 
589
642
  // A pinned minute 0 folds into the cadence with the explicit "0分" so the
@@ -599,6 +652,24 @@ function hourCadence(ir: IR): string | null {
599
652
  prefix + minute + '分' + secondTail(ir);
600
653
  }
601
654
 
655
+ // The cadence a clock-point core (clockTimes/compactClockTimes/composeSeconds)
656
+ // renders an hour stride to, or null. A bare hour stride (minute 0 on the plain
657
+ // :00 second) is the cadence phrase itself — "每2小时", "从0点起每5小时,至20点" —
658
+ // so a short non-tiling stride like */5, which hourCadence keeps enumerating
659
+ // (no minute to fold, nothing to disambiguate), still reads as the cadence. A
660
+ // pinned minute or meaningful second folds into the cadence via hourCadence.
661
+ function hourCadenceText(ir: IR): string | null {
662
+ if (ir.shapes.minute !== 'single') {
663
+ return null;
664
+ }
665
+
666
+ if (+ir.pattern.minute === 0 && ir.pattern.second === '0') {
667
+ return hourCadencePhrase(ir);
668
+ }
669
+
670
+ return hourCadence(ir);
671
+ }
672
+
602
673
  // The fused second tail for an hour cadence: "的每一秒" for a wildcard second,
603
674
  // else "的" + the second's own clause ("的第30秒", "的每15秒").
604
675
  function secondTail(ir: IR): string {
@@ -676,11 +747,13 @@ function minuteClause(ir: IR): string {
676
747
  return valueList(fieldSegments(ir, 'minute'), '分');
677
748
  }
678
749
 
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 === '*';
750
+ // A single second folds into each clock time a clockTimes rest renders
751
+ // ("9点5分30秒"), so it is already spoken; appending the second clause again
752
+ // would double it. A wildcard/list/range second does not fold, so it still
753
+ // leads its own clause after the clock times.
754
+ function clockRestCarriesSecond(rest: PlanNode): boolean {
755
+ return rest.kind === 'clockTimes' &&
756
+ rest.times.some((time) => Boolean(time.second));
684
757
  }
685
758
 
686
759
  // minute = 0 ("on the hour"): render the rest schedule and attach the second.
@@ -710,11 +783,10 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
710
783
  }
711
784
 
712
785
  const restText = render(ir, rest, opts);
786
+ const secTail = clockRestCarriesSecond(rest) ? '' : sec;
713
787
 
714
- if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
715
- if (isDaily(ir)) {
716
- return '每天' + restText + sec;
717
- }
788
+ if (composedClock && isDaily(ir)) {
789
+ return '每天' + restText + secTail;
718
790
  }
719
791
 
720
792
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -723,7 +795,7 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
723
795
  return restText + ',' + sec;
724
796
  }
725
797
 
726
- return restText + sec;
798
+ return restText + secTail;
727
799
  }
728
800
 
729
801
  // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
@@ -733,6 +805,15 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
733
805
  // :00, not 3,600 across the hour) stays visible. The daily frame leads with
734
806
  // 每天; a weekday or date qualifier is added by describe().
735
807
  function composeMinuteZeroClocks(ir: IR, sec: string): string {
808
+ // An hour RANGE (or a list whose segments include a range) reads as the span
809
+ // the source wrote ("9点至17点"), not the wall of clock words it expands to —
810
+ // the hour-RANGE analog of the hour-step cadence. A pure single-value list
811
+ // (9,17) has no range to span and keeps enumerating below.
812
+ if (hasHourWindow(ir)) {
813
+ return isDaily(ir) ? '每天' + hourRangeWindow(ir, sec) :
814
+ hourRangeWindow(ir, sec);
815
+ }
816
+
736
817
  const clocks = hourFires(ir).map(function clock(hour): string {
737
818
  // Noon's word (正午) already pins 12:00, so the "0分" is redundant for it;
738
819
  // midnight (凌晨0点) and other hours still need it to pin the minute.
@@ -747,13 +828,45 @@ function composeMinuteZeroClocks(ir: IR, sec: string): string {
747
828
  return isDaily(ir) ? '每天' + core : core;
748
829
  }
749
830
 
831
+ // Whether the hour field is a range — or a list whose segments include a
832
+ // range — and so forms a window ("9点至17点") rather than a wall of clock
833
+ // words. A pure single-value list (9,17) has no range to span; a step is
834
+ // handled by hourStride/hourCadence.
835
+ function hasHourWindow(ir: IR): boolean {
836
+ return fieldSegments(ir, 'hour').some(function range(segment) {
837
+ return segment.kind === 'range';
838
+ });
839
+ }
840
+
841
+ // The hour-range window under a pinned minute 0 and a meaningful or wildcard
842
+ // second: the hour span list ("9点至17点", "9点至20点和22点") plus the second.
843
+ // A wildcard or sub-minute step second pins the explicit "0分" so the
844
+ // one-minute confinement stays visible ("9点至17点0分的每一秒"), distinct from
845
+ // the bare hourly window ("在9点至17点之间,每小时"); a single/list/range second
846
+ // reads as a clock-point span with the second appended ("9点至17点,第30秒"),
847
+ // matching the folded compact form for the same shape.
848
+ function hourRangeWindow(ir: IR, sec: string): string {
849
+ const span = hourList(ir);
850
+
851
+ if (ir.pattern.second === '*' || ir.shapes.second === 'step') {
852
+ return span + '0分' + (sec === '每秒' ? '的每一秒' : '的' + sec);
853
+ }
854
+
855
+ return span + ',' + sec;
856
+ }
857
+
750
858
  // Wildcard or stepped minute: hang the "每分钟/每N分钟每秒" tail off the hour.
751
859
  function composeSecondsCadence(ir: IR): string {
752
860
  const sec = secondClause(ir);
753
861
  const tail = minuteClause(ir) + sec;
754
862
 
755
- if (isHourCadence(ir)) {
756
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + '的' + tail;
863
+ const hourCad = hourCadencePhrase(ir);
864
+
865
+ if (hourCad !== null) {
866
+ // The cadence absorbs the tail with "的" ("每2小时的每分钟每秒",
867
+ // "从1点起每2小时的每分钟每秒"); a bounded cadence ends on "至K点", so its tail
868
+ // takes a comma to keep that endpoint from reading as a fused clock time.
869
+ return hourCad + (hourCad.indexOf('至') === -1 ? '的' : ',') + tail;
757
870
  }
758
871
 
759
872
  if (ir.shapes.hour === 'single') {
@@ -805,9 +918,10 @@ function composeSecondsListed(ir: IR): string {
805
918
  return minutes + ',' + sec;
806
919
  }
807
920
 
808
- if (isHourCadence(ir)) {
809
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + ',' +
810
- minutes + ',' + sec;
921
+ const hourCad = hourCadencePhrase(ir);
922
+
923
+ if (hourCad !== null) {
924
+ return hourCad + ',' + minutes + ',' + sec;
811
925
  }
812
926
 
813
927
  return hourFrame(ir) + minutes + ',' + sec;
@@ -1141,10 +1255,11 @@ function composeWindow(ir: IR, core: string): string {
1141
1255
  return qualifier(ir) + core;
1142
1256
  }
1143
1257
 
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.
1258
+ // Whether an hour cadence applies — a single pinned minute over an hour stride
1259
+ // (clean, offset, or non-tiling) — so the clock-point plans take the cadence
1260
+ // frame, not the daily one.
1146
1261
  function hourCadenceApplies(ir: IR): boolean {
1147
- return ir.shapes.minute === 'single' && hourCadence(ir) !== null;
1262
+ return hourCadenceText(ir) !== null;
1148
1263
  }
1149
1264
 
1150
1265
  function describe(ir: IR, opts: Opts): string {