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.
@@ -11,7 +11,9 @@
11
11
 
12
12
  import {clockDigits, numeral} from '../../core/format.js';
13
13
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
14
- import {arithmeticStep, toFieldNumber} from '../../core/util.js';
14
+ import {
15
+ arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
16
+ } from '../../core/util.js';
15
17
  import {resolveDialect} from './dialects.js';
16
18
  import type {
17
19
  ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -353,9 +355,13 @@ function composeHourCadence(
353
355
  const clockRest = plan.rest.kind === 'clockTimes' ||
354
356
  plan.rest.kind === 'compactClockTimes';
355
357
 
356
- return clockRest && ir.shapes.minute === 'single' ?
357
- hourCadence(ir, +ir.pattern.minute, opts) :
358
- null;
358
+ if (!clockRest || ir.shapes.minute !== 'single') {
359
+ return null;
360
+ }
361
+
362
+ const minute = +ir.pattern.minute;
363
+
364
+ return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
359
365
  }
360
366
 
361
367
  function renderComposeSeconds(
@@ -400,7 +406,15 @@ function renderComposeSeconds(
400
406
  return secondsLeadClause(ir, opts) + ' joka toisena minuuttina';
401
407
  }
402
408
 
403
- return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
409
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
410
+ // leading clause, so the composer must not prepend a second lead that would
411
+ // double it. A wildcard or stepped second is not folded there (no
412
+ // clockSecond), so it still leads its own clause here.
413
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
414
+ ir.analyses.clockSecond;
415
+ const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
416
+
417
+ return lead + render(ir, plan.rest, opts);
404
418
  }
405
419
 
406
420
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
@@ -627,6 +641,16 @@ function renderMinuteFrequency(
627
641
  const seg = stepSegment(ir.analyses.segments.minute!);
628
642
 
629
643
  if (plan.hours.kind === 'during') {
644
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence
645
+ // after the minute step ("15 minuutin välein, viiden tunnin välein klo
646
+ // 0–20").
647
+ const cadence = unevenHourCadence(ir, opts);
648
+
649
+ if (cadence !== null) {
650
+ return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
651
+ trailingQualifier(ir, opts);
652
+ }
653
+
630
654
  // When the step renders as anchored ("kohdalla"), the per-hour windows
631
655
  // are redundant — use bare clock hours instead, then reorder to
632
656
  // hours-first: "klo <hours> aina minuuttien <spec> kohdalla".
@@ -687,9 +711,20 @@ function renderMinutesAcrossHours(
687
711
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
688
712
  opts: NormalizedOptions
689
713
  ): string {
714
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
715
+ // the minute clause ("joka minuutti, viiden tunnin välein klo 0–20"), not a
716
+ // wall of hour windows.
717
+ const cadence = unevenHourCadence(ir, opts);
718
+
690
719
  if (plan.form === 'wildcard') {
691
- return 'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
692
- trailingQualifier(ir, opts);
720
+ return cadence ?
721
+ 'joka minuutti, ' + cadence + trailingQualifier(ir, opts) :
722
+ 'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
723
+ trailingQualifier(ir, opts);
724
+ }
725
+
726
+ if (cadence !== null) {
727
+ return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
693
728
  }
694
729
 
695
730
  // Range+isolated hours: minute-first, bare minutes, sekä klo.
@@ -714,25 +749,25 @@ function renderMinuteSpanAcrossHourStep(
714
749
  ): string {
715
750
  // An hour-step plan's first hour segment is always a step segment.
716
751
  const segment = stepSegment(ir.analyses.segments.hour!);
752
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
753
+ // offset-clean stride keeps its confinement / per-step phrasing.
754
+ const cadence = unevenHourCadence(ir, opts);
717
755
 
718
756
  // A wildcard span always sets the step off with a comma ("joka
719
757
  // minuutti, joka toinen tunti"); a restricted span joins a plain step
720
758
  // directly ("minuuteilla 0–30 joka toinen tunti").
721
- // A wildcard minute (a cadence) is reached only for a clean step and is
722
- // confined to every Nth hour; a restricted span is a per-hour window whose
723
- // recurrence joins as a plain step.
759
+ // A wildcard minute (a cadence) is reached only for a clean step (a bounded
760
+ // or uneven step routes through minutesAcrossHours instead) and is confined
761
+ // to every Nth hour; a restricted span is a per-hour window + plain step.
724
762
  if (plan.form === 'wildcard') {
725
763
  return 'joka minuutti ' + everyNthHour(segment, opts) +
726
764
  trailingQualifier(ir, opts);
727
765
  }
728
766
 
729
- // A bounded range-step (e.g. 9-17/2) whose fires enumerate as a klo-digit
730
- // list renders hours-first. A clean or offset unbounded step (e.g. 1/6,
731
- // */2) keeps minute-first with its step phrase.
732
- if (segment.startToken.indexOf('-') !== -1) {
733
- const hoursStr = kloList(segment.fires, opts);
734
-
735
- return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
767
+ // A bounded or uneven stride reads as its bounded cadence after the bare
768
+ // minutes ("minuuteilla 0–30, kahden tunnin välein klo 9–17").
769
+ if (cadence !== null) {
770
+ return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
736
771
  }
737
772
 
738
773
  return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
@@ -840,6 +875,15 @@ function renderHourStep(
840
875
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
841
876
  opts: NormalizedOptions
842
877
  ): string {
878
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence
879
+ // ("kahden tunnin välein klo 9–17"); an offset-clean step keeps its bare or
880
+ // "alkaen" cadence.
881
+ const cadence = unevenHourCadence(ir, opts);
882
+
883
+ if (cadence !== null) {
884
+ return cadence + trailingQualifier(ir, opts);
885
+ }
886
+
843
887
  return stepHours(stepSegment(ir.analyses.segments.hour!), opts) +
844
888
  trailingQualifier(ir, opts);
845
889
  }
@@ -867,10 +911,13 @@ function renderClockTimes(
867
911
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
868
912
  opts: NormalizedOptions
869
913
  ): string {
870
- // An hour step (or arithmetic-progression hour list) under a single pinned
871
- // minute reads as a cadence rather than a cross-product of clock times.
914
+ // An hour step or range (or arithmetic-progression hour list) under a single
915
+ // pinned minute reads as a cadence or window rather than a cross-product of
916
+ // clock times.
872
917
  if (ir.shapes.minute === 'single') {
873
- const cadence = hourCadence(ir, +ir.pattern.minute, opts);
918
+ const minute = +ir.pattern.minute;
919
+ const cadence = hourCadence(ir, minute, opts) ??
920
+ hourRangeCadence(ir, minute, opts);
874
921
 
875
922
  if (cadence !== null) {
876
923
  return cadence;
@@ -904,7 +951,8 @@ function renderCompactClockTimes(
904
951
  // minute reads as a cadence, not a wall of clock times. (Returns null for an
905
952
  // irregular list or a range, which keep folding below.)
906
953
  if (plan.fold) {
907
- const cadence = hourCadence(ir, plan.minute, opts);
954
+ const cadence = hourCadence(ir, plan.minute, opts) ??
955
+ hourRangeCadence(ir, plan.minute, opts);
908
956
 
909
957
  if (cadence !== null) {
910
958
  return cadence;
@@ -938,11 +986,16 @@ function renderCompactClockTimes(
938
986
  hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
939
987
  }
940
988
 
941
- // A minute list over purely enumerated hours (step fires, all singles)
942
- // hours-first, drop "joka tunti".
943
- const hoursStr = hourSegmentTimes(ir, 0, null, opts);
944
- const phrase = hoursFirstMinutes(hoursStr, ir, opts) +
945
- trailingQualifier(ir, opts);
989
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence after
990
+ // the bare minute clause ("minuuteilla 0, 25 ja 50, viiden tunnin välein klo
991
+ // 0–20"), not a wall of clock-time columns.
992
+ const cadence = unevenHourCadence(ir, opts);
993
+ const phrase = cadence ?
994
+ bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
995
+ // A minute list over purely enumerated hours (step fires, all singles) —
996
+ // hours-first, drop "joka tunti".
997
+ hoursFirstMinutes(hourSegmentTimes(ir, 0, null, opts), ir, opts) +
998
+ trailingQualifier(ir, opts);
946
999
 
947
1000
  return ir.analyses.clockSecond ?
948
1001
  secondsLeadClause(ir, opts) + ', ' + phrase :
@@ -1128,12 +1181,56 @@ function hourStrideCadence(
1128
1181
  kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
1129
1182
  }
1130
1183
 
1184
+ // An hour list's arithmetic progression, or null when its values are not a step
1185
+ // the renderer should speak as a cadence. The core rewrites a uneven hour step
1186
+ // (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
1187
+ // fire list, indistinguishable in the IR from a hand-written list; the renderer
1188
+ // recovers the cadence from the values. A progression starting at zero is a
1189
+ // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
1190
+ // a step when it is too long to be a deliberate clock-time list (9,17 is two
1191
+ // named times, not a cadence). Interval one is a plain range, never a step.
1192
+ function hourListStride(
1193
+ values: number[]
1194
+ ): {start: number; interval: number; last: number} | null {
1195
+ if (values.length < 2) {
1196
+ return null;
1197
+ }
1198
+
1199
+ const interval = values[1] - values[0];
1200
+
1201
+ if (interval < 2) {
1202
+ return null;
1203
+ }
1204
+
1205
+ for (let i = 2; i < values.length; i += 1) {
1206
+ if (values[i] - values[i - 1] !== interval) {
1207
+ return null;
1208
+ }
1209
+ }
1210
+
1211
+ if (values[0] !== 0 && values.length < 5) {
1212
+ return null;
1213
+ }
1214
+
1215
+ return {interval, last: values[values.length - 1], start: values[0]};
1216
+ }
1217
+
1218
+ // Whether an hour stride wraps the day cleanly from within its first interval
1219
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1220
+ // stride has no distinct endpoint and keeps its bare or "alkaen" cadence. Every
1221
+ // other stride — a uneven interval, or one starting at or past its interval (a
1222
+ // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1223
+ function offsetCleanStride(
1224
+ stride: {start: number; interval: number}
1225
+ ): boolean {
1226
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1227
+ }
1228
+
1131
1229
  // The hour field's stride, or null when the hour is not a cadence: a step
1132
1230
  // segment yields its {start, interval, last} directly; an all-single hour list
1133
- // yields one only when its values form a long-enough arithmetic progression
1134
- // (so an irregular list like 9,17 keeps enumerating). The IR is unchanged —
1135
- // the renderer recognizes the stride and speaks it as a cadence instead of the
1136
- // clock-time cross-product.
1231
+ // yields one only when its values form a step progression (so an irregular list
1232
+ // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1233
+ // the stride and speaks it as a cadence, not the clock-time cross-product.
1137
1234
  function hourStride(
1138
1235
  ir: IR
1139
1236
  ): {start: number; interval: number; last: number} | null {
@@ -1146,6 +1243,13 @@ function hourStride(
1146
1243
 
1147
1244
  if (segments.length === 1 && segments[0].kind === 'step') {
1148
1245
  const segment = segments[0];
1246
+
1247
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1248
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1249
+ if (segment.fires.length < 2) {
1250
+ return null;
1251
+ }
1252
+
1149
1253
  const start = segment.startToken === '*' ?
1150
1254
  0 :
1151
1255
  +segment.startToken.split('-')[0];
@@ -1155,9 +1259,25 @@ function hourStride(
1155
1259
  }
1156
1260
 
1157
1261
  const values = singleValues(segments);
1158
- const step = values && arithmeticStep(values);
1159
1262
 
1160
- return step || null;
1263
+ return values && hourListStride(values);
1264
+ }
1265
+
1266
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1267
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1268
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1269
+ // enumerating list paths; there the bounded hour reads better as its cadence
1270
+ // ("…, viiden tunnin välein klo 0–20") than as a wall of clock times. An
1271
+ // offset-clean stride keeps its existing confinement form, so only the
1272
+ // endpoint-bearing case routes here.
1273
+ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
1274
+ const stride = hourStride(ir);
1275
+
1276
+ if (!stride || offsetCleanStride(stride)) {
1277
+ return null;
1278
+ }
1279
+
1280
+ return hourStrideCadence(stride, opts);
1161
1281
  }
1162
1282
 
1163
1283
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -1214,7 +1334,13 @@ function hourCadence(ir: IR, minute: number,
1214
1334
 
1215
1335
  const fires = (stride.last - stride.start) / stride.interval + 1;
1216
1336
 
1217
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1337
+ // A short stride that spells out as few clock times stays an enumeration only
1338
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1339
+ // or "alkaen" form is no shorter than the list. A bounded or uneven stride
1340
+ // has no clean wrap, so its endpoint-pinning cadence ("viiden tunnin välein
1341
+ // klo 0–20") reads better however short.
1342
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1343
+ offsetCleanStride(stride)) {
1218
1344
  return null;
1219
1345
  }
1220
1346
 
@@ -1232,6 +1358,13 @@ function hourCadence(ir: IR, minute: number,
1232
1358
  everyNthHour(segment, opts) + trailingQualifier(ir, opts);
1233
1359
  }
1234
1360
 
1361
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1362
+ // lead clause to fold in, so the bounded cadence stands on its own ("viiden
1363
+ // tunnin välein klo 0–20").
1364
+ if (minute === 0 && ir.pattern.second === '0') {
1365
+ return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1366
+ }
1367
+
1235
1368
  return hourCadenceLead(ir, minute, opts) + ', ' +
1236
1369
  hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1237
1370
  }
@@ -1249,6 +1382,55 @@ function cleanHourStride(segment: StepSegment): boolean {
1249
1382
  return 24 % segment.interval === 0 && start < segment.interval;
1250
1383
  }
1251
1384
 
1385
+ // Whether the hour field is a range — or a list whose segments include a
1386
+ // range — and so forms a window rather than a cross-product of clock times.
1387
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1388
+ // a step is handled by hourStride/hourCadence.
1389
+ function hasHourWindow(ir: IR): boolean {
1390
+ const segments = ir.analyses.segments.hour;
1391
+
1392
+ return !!segments && segments.some(function range(segment: Segment) {
1393
+ return segment.kind === 'range';
1394
+ });
1395
+ }
1396
+
1397
+ // The hour-range window as a cadence tail at the top of each hour: a lone
1398
+ // range is the bare "klo 9–17"; a range plus a non-contiguous hour joins it
1399
+ // with "sekä klo" ("klo 9–20 sekä klo 22"), the same idiom the bare folded
1400
+ // window uses. The minute has folded into the lead, so the window closes on
1401
+ // the top of its final hour.
1402
+ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
1403
+ return ir.analyses.segments.hour!.length === 1 ?
1404
+ hourSegmentTimes(ir, 0, null, opts) :
1405
+ hourSegmentTimesWithSeka(ir, 0, null, opts);
1406
+ }
1407
+
1408
+ // Render an hour range (or a list whose segments include a range) under
1409
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1410
+ // then "klo 9–17" — instead of cross-multiplying the hours into a wall of
1411
+ // clock times. The hour-RANGE analog of hourCadence. Returns null when the
1412
+ // hour has no range, when the minute is non-zero (a real clock minute the
1413
+ // existing window form already speaks), or when a plain :00 set carries no
1414
+ // clause. Renderer-only; the IR is unchanged.
1415
+ function hourRangeCadence(ir: IR, minute: number,
1416
+ opts: NormalizedOptions): string | null {
1417
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1418
+ return null;
1419
+ }
1420
+
1421
+ const tail = hourRangeWindowTail(ir, opts);
1422
+
1423
+ // A wildcard or sub-minute step second is the whole minute-0 window
1424
+ // ("minuutin ajan", carried by hourCadenceLead), then the window — kept
1425
+ // distinct from the bare "joka tunti klo 9–17" so the confinement is never
1426
+ // heard as it (the hour-range analog of "minuutin ajan joka toisen tunnin
1427
+ // aikana"). A meaningful second leads at its mark, then the window.
1428
+ const joiner = subMinuteSecond(ir) ? ' ' : ', ';
1429
+
1430
+ return hourCadenceLead(ir, minute, opts) + joiner + tail +
1431
+ trailingQualifier(ir, opts);
1432
+ }
1433
+
1252
1434
  // --- Hour-time phrasing. ---
1253
1435
 
1254
1436
  // On-the-hour fires as one klo phrase: "klo 0, 10 ja 20".
@@ -1276,23 +1458,32 @@ function kloFromTimes(
1276
1458
  return hourSegmentTimes(ir, 0, null, opts);
1277
1459
  }
1278
1460
 
1279
- // Each fire hour as its own one-hour dash window under a single klo:
1280
- // "klo 9.00–9.59 ja 17.00–17.59". Finnish prefers this to the English
1281
- // "during the 9 a.m. and 5 p.m. hours" shape.
1461
+ // The hours accompanying a named-once minute clause under an hour list or
1462
+ // step. On-the-hour hours (a fires set, or a segment set with no real range)
1463
+ // are listed once "klo 0, 5, 10, 15 ja 20" — so the minute is never repeated
1464
+ // as a per-hour span. A real hour RANGE segment is a genuine span and keeps its
1465
+ // per-segment window ("klo 8.00–18.59 ja 22.00–22.59"), mirroring the other
1466
+ // languages, which list discrete hours but keep range windows.
1282
1467
  function hourWindowsFromTimes(
1283
1468
  ir: IR,
1284
1469
  times: HourTimesPlan,
1285
1470
  opts: NormalizedOptions
1286
1471
  ): string {
1287
1472
  if (times.kind === 'fires') {
1288
- return 'klo ' + joinList(times.fires.map(function window(hour: number) {
1289
- return hourWindowDigits(hour, opts);
1290
- }));
1473
+ return kloList(times.fires, opts);
1474
+ }
1475
+
1476
+ const segments = ir.analyses.segments.hour!;
1477
+
1478
+ if (!segments.some(function ranged(segment: Segment) {
1479
+ return segment.kind === 'range';
1480
+ })) {
1481
+ return kloList(hourSegmentFires(segments), opts);
1291
1482
  }
1292
1483
 
1293
1484
  const pieces: string[] = [];
1294
1485
 
1295
- ir.analyses.segments.hour!.forEach(function window(segment: Segment) {
1486
+ segments.forEach(function window(segment: Segment) {
1296
1487
  if (segment.kind === 'range') {
1297
1488
  pieces.push(rangeDigits({hour: +segment.bounds[0], minute: 0},
1298
1489
  {hour: +segment.bounds[1], minute: 59}, opts));
@@ -1310,6 +1501,23 @@ function hourWindowsFromTimes(
1310
1501
  return 'klo ' + joinList(pieces);
1311
1502
  }
1312
1503
 
1504
+ // The on-the-hour fires of a range-free hour segment set, in order: a step
1505
+ // segment contributes its enumerated fires, a single its one value.
1506
+ function hourSegmentFires(segments: Segment[]): number[] {
1507
+ const hours: number[] = [];
1508
+
1509
+ segments.forEach(function each(segment: Segment) {
1510
+ if (segment.kind === 'step') {
1511
+ hours.push(...segment.fires);
1512
+ }
1513
+ else if (segment.kind === 'single') {
1514
+ hours.push(+segment.value);
1515
+ }
1516
+ });
1517
+
1518
+ return hours;
1519
+ }
1520
+
1313
1521
  // "9.00–9.59": one hour as a dash window, in digits.
1314
1522
  function hourWindowDigits(hour: number, opts: NormalizedOptions): string {
1315
1523
  return rangeDigits({hour, minute: 0}, {hour, minute: 59}, opts);
@@ -1497,7 +1705,9 @@ function weekdayQualifier(ir: IR): string {
1497
1705
  return quartz;
1498
1706
  }
1499
1707
 
1500
- const segments = flattenSteps(ir.analyses.segments.weekday!);
1708
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
1709
+ // form. The IR stays canonical (Sunday=0). The helper flattens steps.
1710
+ const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
1501
1711
 
1502
1712
  return joinList(segments.map(function piece(segment: FlatSegment) {
1503
1713
  if (segment.kind === 'range') {