@ultimat3/time 23.0.0 → 25.0.0

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.
package/CLAUDE.md CHANGED
@@ -21,12 +21,15 @@
21
21
  | `business.ts` | weekends as config, holidays as local dates |
22
22
  | `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
23
23
  | `plain-date.ts` | `PlainDate` — a calendar date with no time and no zone, and the two conversions to an instant |
24
+ | `plain-date-range.ts` | `plainDateRange` — the bounded, frozen list of dates between two `PlainDate`s |
25
+ | `months.ts` | `addPlainMonths` / `addMonthsInZone` — month steps, the day clamped to the target month |
26
+ | `holidays.ts` | `holidaySet` — the memoised `Set` `isHoliday` reads, one per holiday list |
24
27
 
25
28
  ## Rules
26
29
 
27
30
  - Never format without an explicit `timeZone`. No ambient default, no `toLocaleString()`.
28
31
  - **The ambient zone IS `Ctx.tz`**, core's own declared field. This package publishes no writer and
29
- no field of its own: `createContext({ tz })` and `withChildContext({ tz })` are the way in,
32
+ no field of its own: `ctxOf({ tz })` and `withChildContext({ tz })` are the way in,
30
33
  `currentTimeZone()` the way out. It kept `attachTimeZone`/`timeZoneOf` over `ctx['timeZone']`
31
34
  until 1.3.0 — a second ambient store, with **zero** writers, while `@ultimat3/http` wrote `tz` —
32
35
  so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render
@@ -143,6 +146,44 @@
143
146
  applies ONLY when day-of-month is restricted and day-of-week is not: Vixie's OR means
144
147
  `0 0 30 2 5` fires every Friday in February, so refusing it would break a working schedule.
145
148
  `MAX_STEPS` stays as the backstop for what the check cannot see.
149
+ - **The repeated hour of a fall-back night is run through by an INTERVAL and fired once by a FIXED
150
+ time, `As of 2026-10`.** `CronExpression.wildcardTime` — the minute or hour field spelled `*` or
151
+ `*/n`, Vixie's test — is the whole decision. The wall-clock walk alone cannot see the hour come
152
+ round again (from 02:55 it reaches 03:00), so `*/5 * * * *` went dark 01:00Z–02:00Z in Berlin
153
+ once a year while `matchesCron` answered `true` inside it. `nextCronOccurrence` walks a second
154
+ time from the wall clock the transition lands on. A fixed time never returns its second pass,
155
+ from ANY `after` — `@ultimat3/jobs`' `latestOccurrence` bisects over `after`, so the answer must
156
+ be monotone in it; `cron-occurrence.test.ts` pins that. `matchesCron` stays the pure wall-clock
157
+ predicate and is `true` for a fixed time's second pass: it answers "does this instant's clock
158
+ match", not "is this an occurrence".
159
+ - **`addBusinessDays` builds every candidate from the calendar date + the ORIGINAL wall time.**
160
+ Chained through `addDaysInZone`, a 02:30 that crossed a spring-forward Sunday became 03:30 and
161
+ stayed 03:30 on Monday. `businessDaysBetween` walks the same dates (`localDay`): a chain steps
162
+ OVER a date the zone never had (Samoa, 2011-12-30) and so counted the day `to` excludes. A date
163
+ that does not exist is not a day of either kind.
164
+ - **`formatRelative` takes `zone`, and from a day apart counts CALENDAR days** (`daysBetween`).
165
+ `trunc(47h / 24h)` called two midnights away "tomorrow". Under a day it is elapsed time; a full
166
+ day apart with no midnight crossed (a 25-hour day) is hours, not "today". **Breaking.**
167
+ - **A cron name matches EXACTLY** — its three letters or its whole word. First-three-letters
168
+ matching read `marzipan` as March. Extra `-` and `/` parts are refused, not dropped.
169
+ - **A `PlainDate` has four year digits.** `plainDateUtc` (and so `addPlainDays`) and `plainDateIn`
170
+ refuse a year outside 0000-9999 rather than brand `10000-01-01`, which `isPlainDate` rejects.
171
+ - **Months clamp, and the zoned step is Temporal's `'compatible'`, `As of 2026-10`.**
172
+ `addMonthsInZone` moves the LOCAL date by `addPlainMonths` and resolves the original wall time
173
+ once, `{ gap: 'next', overlap: 'first' }` — forward by the gap, the earlier offset in an overlap.
174
+ Not an option: a second rule is a second answer. `months === 0` returns a copy, because
175
+ re-resolving an overlap's second pass would answer the first. A recurring date is computed from
176
+ its anchor; a clamp does not compose (`months.test.ts` pins both). Its refusals carry the
177
+ corrected call as their fix (`monthsInvalid`) — an out-of-range count names the furthest one
178
+ that lands in 0000-9999.
179
+ - **The holiday `Set` is keyed on the LIST, in a `WeakMap`, and the list is FROZEN when it is
180
+ built.** Keyed on the calendar, a replaced `holidays` would answer stale. An in-place edit would
181
+ too, and detecting one is a pass over the list per lookup — the cost the set removes — so it is
182
+ made impossible: after first use an edit throws `TypeError` at the line that tried it. The WeakMap
183
+ is the bound. `holidays.test.ts` counts builds through a Proxy, so a lost memo is a failing number.
184
+ - **`plainDateRange` refuses a span over `MAX_PLAIN_DATE_RANGE_DAYS` (3660) before allocating**, and
185
+ a reversed range rather than answering it empty. Each refusal is `X_SCHEDULE_INVALID` with its
186
+ OWN fix, the corrected call (`rangeInvalid`), never `scheduleInvalid`'s generic field prose.
146
187
  - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
147
188
  - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
148
189
  - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
package/README.md CHANGED
@@ -58,6 +58,11 @@ formatWithOffset(at, { locale: 'en-GB', zone: 'America/New_York' });
58
58
  nextLocalSlot({ zone: 'Asia/Kathmandu', hour: 9 }, now(clock)); // 09:00 local, +05:45 handled
59
59
  ```
60
60
 
61
+ `formatRelative(at, { locale, zone, now })` — `in 3 days`, `2 hours ago`. `zone` is required: from
62
+ a day apart the number is **calendar days in that zone** (`daysBetween`), so 47 hours ahead across
63
+ two midnights is `in 2 days`, never `tomorrow`. Under a day apart it is elapsed hours, minutes or
64
+ seconds.
65
+
61
66
  ## DST is a policy, not a guess
62
67
 
63
68
  `fromZoned(wall, zone, { gap, overlap })` is the function everything else is built on.
@@ -84,9 +89,10 @@ one of these takes the zone explicitly, and none of them has a default.
84
89
  | Function | Answers |
85
90
  |---|---|
86
91
  | `startOfDay(at, zone)` / `endOfDay(at, zone)` | local midnight; the last millisecond of the day |
87
- | `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away |
92
+ | `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away. `days` is a whole number — `NaN` and `0.5` are `X_SCHEDULE_INVALID` |
88
93
  | `daysBetween(from, to, zone)` | local day boundaries crossed — signed, always integral |
89
94
  | `isoDateInZone(at, zone)` | `2026-03-14` |
95
+ | `isoInZone(at, zone)` | `2026-03-08T03:00:00-04:00` — ISO-8601 with the zone's own offset, never `Z` for a non-UTC zone. `toIso` is the UTC form |
90
96
  | `isSameLocalDay(left, right, zone)` | whether two instants share a local date |
91
97
 
92
98
  `daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
@@ -109,7 +115,7 @@ half the planet.
109
115
  | `plainDateIn(at, zone)` | the calendar date an **instant** falls on, in a named zone. It takes the zone because there is no other honest way to make this conversion |
110
116
  | `plainDateUtc(at)` | the date a `Date` holds read as UTC — for the one caller that needs it: a Postgres driver returns a `date` column as midnight UTC |
111
117
  | `plainDateToUtcInstant(date)` | midnight UTC of the date. The inverse of `plainDateUtc`, and never of `plainDateIn` |
112
- | `addPlainDays(date, days)` / `plainDaysBetween(from, to)` | calendar arithmetic with no zone in it: a DST day is one day, because there is no zone to shorten |
118
+ | `addPlainDays(date, days)` / `plainDaysBetween(from, to)` | calendar arithmetic with no zone in it: a DST day is one day, because there is no zone to shorten. A result outside years `0000`-`9999` is `X_SCHEDULE_INVALID` |
113
119
  | `comparePlainDates(a, b)` | `-1` / `0` / `1` |
114
120
 
115
121
  It is a branded **string**, and both halves are load-bearing. Not a `Date`: a `Date` is an instant,
@@ -120,13 +126,54 @@ through `JSON.stringify` as itself, and is the literal Postgres accepts and retu
120
126
 
121
127
  `@ultimat3/entity`'s `date()` column is the one that stores it.
122
128
 
129
+ ## Months clamp
130
+
131
+ A month has no fixed length, so month arithmetic clamps the day to the target month's last day —
132
+ the same rule for a plain date and for a zoned instant.
133
+
134
+ | Function | Answers |
135
+ |---|---|
136
+ | `addPlainMonths(date, months)` | `2026-01-31 + 1` → `2026-02-28`; `2024-01-31 + 1` → `2024-02-29`; `2026-03-31 − 1` → `2026-02-28`. Negative counts go back, the year rolls. A whole number of months, a result in `0000`-`9999`, or `X_SCHEDULE_INVALID` |
137
+ | `addMonthsInZone(at, months, timeZone)` | the same LOCAL wall-clock time, `months` calendar months away; the local date clamps as above. `timeZone` is required |
138
+ | `plainDateRange(start, end, { exclusive?, stepDays? })` | a frozen array of dates, `[start, end]` by default, `[start, end)` with `exclusive: true`. A reversed range, a step below 1 or a span over `MAX_PLAIN_DATE_RANGE_DAYS` (3660) is `X_SCHEDULE_INVALID` |
139
+
140
+ `addMonthsInZone` resolves a target wall time that does not exist, or exists twice, by Temporal's
141
+ `'compatible'` rule — fixed, not an option:
142
+
143
+ | Target wall time | Resolves to |
144
+ |---|---|
145
+ | spring-forward **gap** | moved **forward** by the gap: 02:30 New York → 03:30 EDT; 02:15 Lord Howe (30-minute shift) → 02:45 |
146
+ | fall-back **overlap** | the **earlier** instant: 01:30 New York → 01:30 EDT |
147
+
148
+ **A recurring date is computed from its anchor, never chained.** A clamp does not compose:
149
+ `2026-01-31 + 1 + 1` is `2026-03-28`, `2026-01-31 + 2` is `2026-03-31`.
150
+
151
+ ```ts
152
+ import { addMonthsInZone, fromIso } from '@ultimat3/time';
153
+
154
+ const anchor = fromIso('2026-01-31T00:00:00-05:00');
155
+ const renewal = (n: number) => addMonthsInZone(anchor, n, 'America/Bogota');
156
+ // renewal(1): Feb 28 00:00, renewal(2): Mar 31 00:00 — never stuck on the 28th
157
+ ```
158
+
123
159
  ## Cron
124
160
 
125
161
  `parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
126
162
  macros, and Vixie's dom/dow OR rule. `nextCronOccurrence(expr, zone, after)` iterates the
127
163
  zone's **wall clock**, so `0 3 * * *` in `Europe/Berlin` stays at 03:00 local across a DST
128
164
  change, and a job scheduled inside the gap runs at the first existing local time instead of
129
- being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
165
+ being skipped. A name is its three letters or its whole word (`mon`, `monday`) — never a prefix
166
+ match — and a range has two ends, a step one: `marzipan`, `1-5-7` and `1/2/3` are `X_CRON_INVALID`.
167
+
168
+ On a fall-back night the repeated hour is decided by what the expression names
169
+ (`CronExpression.wildcardTime`, Vixie's rule):
170
+
171
+ | Expression | In the repeated hour |
172
+ |---|---|
173
+ | a fixed time — `30 2 * * *` | fires **once**, on the first pass, whichever instant it is asked from |
174
+ | an interval — minute or hour field `*` or `*/n`: `*/5 * * * *`, `0 * * * *` | runs through **both** passes; no hour of the night is skipped |
175
+
176
+ `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
130
177
  weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
131
178
  because tier 1 cannot reach `t()` and a built-in default would ship English to every locale. A
132
179
  long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
@@ -139,6 +186,29 @@ ten-second step used to render as "every minute".
139
186
  The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
140
187
  `WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
141
188
 
189
+ `addBusinessDays(at, days, calendar)` keeps the **original** wall-clock time: every candidate is the
190
+ calendar date plus the time the caller started with, so a spring-forward day on the way does not
191
+ shift the days after it. A calendar with no business day in it — every weekday in `weekendDays`, or
192
+ every remaining day a holiday for ten years — is refused with `X_SCHEDULE_INVALID`, never answered
193
+ with a weekend day.
194
+
195
+ **Closures are dates.** A multi-week closure is a range spread into the one holiday list — there is
196
+ no second `closures` field. `isHoliday` builds a `Set` once per list and reuses it, so a long list
197
+ costs one lookup per day walked. The list is **frozen** on first use, so the set can never go
198
+ stale: an in-place edit afterwards throws `TypeError` — build a new list to change it.
199
+
200
+ ```ts
201
+ import { type BusinessCalendar, plainDate, plainDateRange } from '@ultimat3/time';
202
+
203
+ const calendar: BusinessCalendar = {
204
+ zone: 'Europe/Berlin',
205
+ holidays: [
206
+ '2026-10-03',
207
+ ...plainDateRange(plainDate('2026-12-20'), plainDate('2027-01-10')),
208
+ ],
209
+ };
210
+ ```
211
+
142
212
  `businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
143
213
  days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
144
214
  neither endpoint's wall-clock time is part of the question. A reversed range is the same count
@@ -156,7 +226,7 @@ negated; an empty one is `0` in either direction, never `-0`.
156
226
  | `X_INSTANT_INVALID` | unparseable timestamp |
157
227
  | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached any entry point taking a `locale`. Declared by `@ultimat3/core` `As of 2026-08`, thrown here unchanged — `@ultimat3/time` owned it until then. It answers **400**, not 500: the `locale` stage negotiates `Accept-Language` and never throws, so a tag that reaches this code came from a path, query or action input the caller wrote |
158
228
  | `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
159
- | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` |
229
+ | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday`; a fractional or non-finite `days`; a business calendar with no business day in it |
160
230
 
161
231
  ### Error classes
162
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,14 +28,14 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "engines": {
31
- "bun": ">=1.4.0"
31
+ "bun": ">=1.4.2"
32
32
  },
33
33
  "scripts": {
34
34
  "typecheck": "tsc --noEmit -p tsconfig.json",
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/core": "23.0.0",
39
- "@ultimat3/schema": "23.0.0"
38
+ "@ultimat3/core": "25.0.0",
39
+ "@ultimat3/schema": "25.0.0"
40
40
  }
41
41
  }
package/src/business.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
4
  */
5
5
 
6
- import { scheduleInvalid } from './errors';
6
+ import { scheduleInvalid, type TimeError } from './errors';
7
+ import { holidaySet } from './holidays';
7
8
  import type { Instant } from './instant';
8
- import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
9
- import type { TimeZone } from './zones';
9
+ import { daysBetween, fromZoned, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
10
+ import { type TimeZone, utcEpoch } from './zones';
10
11
 
11
12
  /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
12
13
  export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
@@ -15,6 +16,7 @@ export const WEEKEND_SAT_SUN: readonly IsoWeekday[] = [6, 7];
15
16
  /** Gulf states, and the default in Israel (Fri/Sat). */
16
17
  export const WEEKEND_FRI_SAT: readonly IsoWeekday[] = [5, 6];
17
18
  export const WEEKEND_SUN_ONLY: readonly IsoWeekday[] = [7];
19
+ const EVERY_WEEKDAY: readonly IsoWeekday[] = [1, 2, 3, 4, 5, 6, 7];
18
20
 
19
21
  export interface BusinessCalendar {
20
22
  zone: TimeZone;
@@ -29,8 +31,8 @@ export function isWeekend(at: Instant, zone: TimeZone, weekendDays = WEEKEND_SAT
29
31
  }
30
32
 
31
33
  export function isHoliday(at: Instant, calendar: BusinessCalendar): boolean {
32
- const holidays = calendar.holidays ?? [];
33
- return holidays.includes(isoDateInZone(at, calendar.zone));
34
+ // A `Set` built once per list (`holidays.ts`): every business-day walk asks this per day.
35
+ return holidaySet(calendar.holidays).has(isoDateInZone(at, calendar.zone));
34
36
  }
35
37
 
36
38
  /** A business day is a non-weekend, non-holiday local day. */
@@ -56,19 +58,84 @@ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCal
56
58
  throw scheduleInvalid('days', days, 'a whole number of business days');
57
59
  }
58
60
  if (days === 0) return at;
61
+ // The misconfiguration this refusal is usually about, answered before ten years of walking.
62
+ const weekend = calendar.weekendDays ?? WEEKEND_SAT_SUN;
63
+ if (EVERY_WEEKDAY.every((day) => weekend.includes(day))) {
64
+ throw noBusinessDay(calendar);
65
+ }
59
66
  const step = days > 0 ? 1 : -1;
67
+ const origin = toZoned(at, calendar.zone);
60
68
  let remaining = Math.abs(days);
61
- let cursor = at;
69
+ let offset = 0;
62
70
  let guard = 0;
71
+ let landed = at;
63
72
 
64
73
  while (remaining > 0) {
65
- cursor = addDaysInZone(cursor, step, calendar.zone);
66
- if (isBusinessDay(cursor, calendar)) remaining -= 1;
74
+ offset += step;
75
+ const candidate = localDay(origin, offset, calendar.zone);
76
+ if (candidate !== null && isBusinessDay(candidate, calendar)) {
77
+ remaining -= 1;
78
+ landed = candidate;
79
+ }
67
80
  guard += 1;
68
- // A calendar with every day marked as a holiday would otherwise loop forever.
69
- if (guard > Math.abs(days) * 7 + 3650) return cursor;
81
+ // Ten years past the last day a full week could need without one business day: the calendar has
82
+ // none to give. It used to RETURN here — the day the guard stopped on, a weekend or a holiday —
83
+ // which a caller schedules against as if it were the business day it asked for.
84
+ if (guard > Math.abs(days) * 7 + 3650) throw noBusinessDay(calendar);
70
85
  }
71
- return cursor;
86
+ return landed;
87
+ }
88
+
89
+ /**
90
+ * The refusal for a calendar the loop above can never land in. Which list is at fault decides the
91
+ * repair: a weekend covering all seven days, or — the weekend leaving days — holidays covering them.
92
+ */
93
+ function noBusinessDay(calendar: BusinessCalendar): TimeError {
94
+ const weekend = calendar.weekendDays ?? WEEKEND_SAT_SUN;
95
+ if (EVERY_WEEKDAY.every((day) => weekend.includes(day))) {
96
+ return scheduleInvalid(
97
+ 'calendar.weekendDays',
98
+ weekend,
99
+ 'ISO weekdays (1-7) that leave at least one business day',
100
+ );
101
+ }
102
+ return scheduleInvalid(
103
+ 'calendar.holidays',
104
+ (calendar.holidays ?? []).length,
105
+ 'dates that leave at least one business day in any ten-year window (remove some of them)',
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The calendar date `offset` days from `origin`'s, at `origin`'s wall time — or `null` when the
111
+ * zone never had that date (Samoa's 2011-12-30).
112
+ *
113
+ * Built from the calendar date and the ORIGINAL wall time, never from the day before it. Chaining
114
+ * `addDaysInZone` a day at a time carried a DST repair forward: 02:30 through a spring-forward
115
+ * Sunday became 03:30 there and stayed 03:30 on Monday, where 02:30 exists. `fromZoned` normalizes
116
+ * the day overflow, so `day + offset` is the whole walk.
117
+ */
118
+ function localDay(origin: ZonedDateTime, offset: number, zone: TimeZone): Instant | null {
119
+ const candidate = fromZoned(
120
+ {
121
+ year: origin.year,
122
+ month: origin.month,
123
+ day: origin.day + offset,
124
+ hour: origin.hour,
125
+ minute: origin.minute,
126
+ second: origin.second,
127
+ millisecond: origin.millisecond,
128
+ },
129
+ zone,
130
+ { gap: 'next' },
131
+ );
132
+ const wanted = new Date(utcEpoch(origin.year, origin.month, origin.day + offset));
133
+ const got = toZoned(candidate, zone);
134
+ const exists =
135
+ got.year === wanted.getUTCFullYear() &&
136
+ got.month === wanted.getUTCMonth() + 1 &&
137
+ got.day === wanted.getUTCDate();
138
+ return exists ? candidate : null;
72
139
  }
73
140
 
74
141
  /** The next business day at the same local time; today if it already qualifies. */
@@ -97,13 +164,14 @@ export function businessDaysBetween(
97
164
  const start = sign === 1 ? from : to;
98
165
  const end = sign === 1 ? to : from;
99
166
  // `daysBetween` is the day count of the same half-open interval, so it is also the exact number
100
- // of iterations — the loop cannot run away on a calendar `addDaysInZone` handles oddly.
167
+ // of iterations. Each one is a calendar DATE counted from `start`'s — not the instant a day after
168
+ // the previous one, which steps over a date the zone skipped and so reaches past `to`.
101
169
  const days = daysBetween(start, end, calendar.zone);
102
- let cursor = start;
170
+ const origin = toZoned(start, calendar.zone);
103
171
  let count = 0;
104
172
  for (let index = 0; index < days; index += 1) {
105
- if (isBusinessDay(cursor, calendar)) count += 1;
106
- cursor = addDaysInZone(cursor, 1, calendar.zone);
173
+ const day = localDay(origin, index, calendar.zone);
174
+ if (day !== null && isBusinessDay(day, calendar)) count += 1;
107
175
  }
108
176
  // Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
109
177
  // `-0`, which `Object.is`, a `Map` key and every `toBe` treat as a value distinct from `0`.
package/src/context.ts CHANGED
@@ -57,11 +57,12 @@ let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
57
57
  * request, but a default nothing can fall back to is a boot-time mistake with no second answer.
58
58
  */
59
59
  export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
60
- const defaultZone =
61
- partial.defaultZone === undefined ? undefined : assertTimeZone(partial.defaultZone);
60
+ // `defaultZone: undefined` means "not given" and must not be spread over the zone in force.
61
+ const { defaultZone: given, ...rest } = partial;
62
+ const defaultZone = given === undefined ? undefined : assertTimeZone(given);
62
63
  config = {
63
64
  ...config,
64
- ...partial,
65
+ ...rest,
65
66
  ...(defaultZone === undefined ? {} : { defaultZone }),
66
67
  };
67
68
  return config;
@@ -100,7 +101,7 @@ export function resolveTimeZone(
100
101
  * It used to be a `ctx['timeZone']` key nothing in the framework ever set, while the HTTP pipeline
101
102
  * wrote `ctx.tz`: two ambient answers to one question, and the one every `@ultimat3/ui` component
102
103
  * reads on a server render was the empty one, so every date rendered in UTC however the request
103
- * arrived. `withChildContext({ tz })` and `createContext({ tz })` are therefore the only writers,
104
+ * arrived. `withChildContext({ tz })` and `ctxOf({ tz })` are therefore the only writers,
104
105
  * which is what makes a subtree's zone a core concept rather than this package's.
105
106
  */
106
107
  export function currentTimeZone(): TimeZone {
@@ -3,11 +3,12 @@
3
3
  * converted once with `fromZoned` so DST gaps and overlaps resolve correctly.
4
4
  */
5
5
 
6
+ import { finiteCount } from '@ultimat3/core';
6
7
  import { type CronExpression, matchesDay, parseCronOnce } from './cron-parse';
7
8
  import { cronInvalid } from './errors';
8
9
  import { fromEpochMs, type Instant } from './instant';
9
10
  import { fromZoned, toZoned } from './zoned';
10
- import { assertTimeZone, type TimeZone, utcEpoch } from './zones';
11
+ import { assertTimeZone, offsetAt, type TimeZone, utcEpoch } from './zones';
11
12
 
12
13
  /** True when `at` matches the expression in `zone`, to the second. */
13
14
  export function matchesCron(
@@ -38,7 +39,15 @@ const MAX_STEPS = 200_000;
38
39
  * and converted with `fromZoned({ gap: 'next', overlap: 'first' })`:
39
40
  * - spring forward: a 02:30 daily job runs once, at the first existing local time after
40
41
  * the gap, instead of being silently skipped;
41
- * - fall back: a repeated local time runs once, on its first occurrence.
42
+ * - fall back, a FIXED time (`30 2 * * *`): the repeated local time runs once, on its first
43
+ * occurrence — asked from anywhere, including from inside the repeated hour;
44
+ * - fall back, an INTERVAL (every five minutes, `0 * * * *` — `CronExpression.wildcardTime`):
45
+ * the repeated hour is real elapsed time and the schedule runs through both passes of it.
46
+ *
47
+ * The last two are Vixie cron's rule, and they are one rule about what the author named: a fixed
48
+ * time names a moment of the day, an interval names a rhythm. The wall clock alone could not say
49
+ * it — walking it forward from 02:55 reaches 03:00 and never sees 02:00 come round again, so
50
+ * every interval schedule went dark for one hour a year.
42
51
  */
43
52
  export function nextCronOccurrence(
44
53
  expression: string | CronExpression,
@@ -47,16 +56,77 @@ export function nextCronOccurrence(
47
56
  ): Instant {
48
57
  const cron = parseCronOnce(expression);
49
58
  assertTimeZone(zone);
59
+ const source = typeof expression === 'string' ? expression : cron.source;
50
60
 
51
61
  const start = toZoned(after, zone);
52
- const cursor: Cursor = {
62
+ const first = walk(cron, zone, source, after, {
53
63
  year: start.year,
54
64
  month: start.month,
55
65
  day: start.day,
56
66
  hour: start.hour,
57
67
  minute: start.minute,
58
68
  second: start.second + 1,
59
- };
69
+ });
70
+ if (!cron.wildcardTime) return first;
71
+
72
+ // The second pass of an overlap that begins AFTER `after`. Its wall times are behind the cursor
73
+ // the first walk started from, so that walk cannot have met them; they are met here, by walking
74
+ // again from the wall clock the transition lands on. Only a transition inside the overlap's own
75
+ // reach matters — past it, every repeated wall time has a first-pass instant that is itself
76
+ // after `after`, and the first walk already answered with something at or before it.
77
+ const horizon = Math.min(first.getTime(), after.getTime() + OVERLAP_REACH_MS);
78
+ const offsetBefore = offsetAt(zone, after);
79
+ if (offsetAt(zone, fromEpochMs(horizon)) >= offsetBefore) return first;
80
+ const landed = toZoned(fromEpochMs(fallBackAt(zone, after.getTime(), horizon)), zone);
81
+ const repeated = walk(
82
+ cron,
83
+ zone,
84
+ source,
85
+ after,
86
+ {
87
+ year: landed.year,
88
+ month: landed.month,
89
+ day: landed.day,
90
+ hour: landed.hour,
91
+ minute: landed.minute,
92
+ second: landed.second,
93
+ },
94
+ 'second',
95
+ );
96
+ return repeated.getTime() < first.getTime() ? repeated : first;
97
+ }
98
+
99
+ /**
100
+ * How far ahead of `after` a fall-back can begin and still repeat a wall time `after` has already
101
+ * passed. A day and a bit: no zone's clock has ever been set back by more than a day.
102
+ */
103
+ const OVERLAP_REACH_MS = 26 * 3_600_000;
104
+
105
+ /** The first epoch ms in `(from, to]` whose offset is below `from`'s — found by bisection. */
106
+ function fallBackAt(zone: TimeZone, from: number, to: number): number {
107
+ const before = offsetAt(zone, fromEpochMs(from));
108
+ let lo = from;
109
+ let hi = to;
110
+ while (hi - lo > 1) {
111
+ const mid = lo + Math.floor((hi - lo) / 2);
112
+ if (offsetAt(zone, fromEpochMs(mid)) < before) hi = mid;
113
+ else lo = mid;
114
+ }
115
+ return hi;
116
+ }
117
+
118
+ /**
119
+ * Walk the wall clock forward from `cursor` (inclusive) to the first matching wall time whose
120
+ * instant is after `after`. `overlap` is which pass a repeated wall time resolves to FIRST.
121
+ */
122
+ function walk(
123
+ cron: CronExpression,
124
+ zone: TimeZone,
125
+ source: string,
126
+ after: Instant,
127
+ cursor: Cursor,
128
+ overlap: 'first' | 'second' = 'first',
129
+ ): Instant {
60
130
  carry(cursor);
61
131
 
62
132
  for (let step = 0; step < MAX_STEPS; step += 1) {
@@ -99,8 +169,14 @@ export function nextCronOccurrence(
99
169
  continue;
100
170
  }
101
171
 
102
- const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'first' });
172
+ const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap });
103
173
  if (candidate.getTime() > after.getTime()) return candidate;
174
+ // `after` is INSIDE the second pass of a repeated hour, so the first pass of this wall time is
175
+ // behind it. An interval schedule's next run is the second pass; a fixed time already ran.
176
+ if (cron.wildcardTime && overlap === 'first') {
177
+ const later = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'second' });
178
+ if (later.getTime() > after.getTime()) return later;
179
+ }
104
180
  // The DST gap can push a candidate onto an instant we have already passed; step on.
105
181
  cursor.second += 1;
106
182
  carry(cursor);
@@ -110,22 +186,28 @@ export function nextCronOccurrence(
110
186
  // February — is refused by `parseCron` in constant time, because reaching it here cost ~150ms of
111
187
  // blocking CPU per call and `firedSince` pays that per tick of the scheduler's leader loop.
112
188
  throw cronInvalid(
113
- typeof expression === 'string' ? expression : cron.source,
189
+ source,
114
190
  `no occurrence after ${MAX_STEPS} search steps — the date fields can never all match`,
115
191
  );
116
192
  }
117
193
 
118
- /** The next `count` occurrences, each strictly after the previous one. */
194
+ /**
195
+ * The next `count` occurrences, each strictly after the previous one.
196
+ *
197
+ * `count` is screened because it bounds the loop: `NaN` answered `[]` — "no occurrences" — `2.5`
198
+ * ran three times, and `Infinity` pushed until the process ran out of memory.
199
+ */
119
200
  export function nextCronOccurrences(
120
201
  expression: string | CronExpression,
121
202
  zone: TimeZone,
122
203
  after: Instant,
123
204
  count: number,
124
205
  ): Instant[] {
206
+ const total = finiteCount('nextCronOccurrences', 'count', count, 0);
125
207
  const cron = parseCronOnce(expression);
126
208
  const results: Instant[] = [];
127
209
  let cursor = after;
128
- for (let index = 0; index < count; index += 1) {
210
+ for (let index = 0; index < total; index += 1) {
129
211
  cursor = nextCronOccurrence(cron, zone, cursor);
130
212
  results.push(cursor);
131
213
  }
package/src/cron-parse.ts CHANGED
@@ -18,6 +18,11 @@ export interface CronExpression {
18
18
  /** Vixie semantics: when both day fields are restricted, either one matching is a hit. */
19
19
  dayOfMonthRestricted: boolean;
20
20
  dayOfWeekRestricted: boolean;
21
+ // The minute or the hour field is `*` or a stepped `*` — an INTERVAL, not a time of day. Vixie's
22
+ // test, and it decides one thing: whether the repeated hour of a fall-back night is run through
23
+ // (every five minutes) or fired once (02:30 daily). A fact about how the field was spelled,
24
+ // which the value lists cannot carry — `0-59` and `*` hold the same sixty numbers.
25
+ wildcardTime: boolean;
21
26
  }
22
27
 
23
28
  // A `Map`, not an object literal: the key is a caller's string, and `MACROS['constructor']` on a
@@ -50,6 +55,7 @@ const MONTH_NAMES = [
50
55
  'dec',
51
56
  ];
52
57
  const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
58
+ const DAY_LABELS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
53
59
 
54
60
  /** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
55
61
  export function parseCron(expression: string): CronExpression {
@@ -69,10 +75,10 @@ export function parseCron(expression: string): CronExpression {
69
75
  const minutes = parseField(expression, minuteField ?? '*', 0, 59);
70
76
  const hours = parseField(expression, hourField ?? '*', 0, 23);
71
77
  const daysOfMonth = parseField(expression, domField ?? '*', 1, 31);
72
- const months = parseField(expression, monthField ?? '*', 1, 12, MONTH_NAMES, 1);
78
+ const months = parseField(expression, monthField ?? '*', 1, 12, [MONTH_NAMES, MONTH_LABELS], 1);
73
79
  // Span 7, not `max - min + 1` = 8: the dow field accepts 0-7 because Sunday has two spellings,
74
80
  // so the modulus a wrap strides over is a week with a phantom day in it unless it is stated.
75
- const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0, 7);
81
+ const rawDow = parseField(expression, dowField ?? '*', 0, 7, [DAY_NAMES, DAY_LABELS], 0, 7);
76
82
 
77
83
  // 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
78
84
  const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
@@ -87,6 +93,7 @@ export function parseCron(expression: string): CronExpression {
87
93
  daysOfWeek,
88
94
  dayOfMonthRestricted: isRestricted(domField ?? '*'),
89
95
  dayOfWeekRestricted: isRestricted(dowField ?? '*'),
96
+ wildcardTime: isWildcard(minuteField ?? '*') || isWildcard(hourField ?? '*'),
90
97
  };
91
98
  assertReachableDate(expression, parsed);
92
99
  return parsed;
@@ -164,7 +171,7 @@ function parseField(
164
171
  field: string,
165
172
  min: number,
166
173
  max: number,
167
- names: readonly string[] = [],
174
+ names: readonly (readonly string[])[] = [],
168
175
  nameOffset = 0,
169
176
  /**
170
177
  * How many distinct values one full turn of this field has — `max - min + 1` for every field
@@ -177,8 +184,11 @@ function parseField(
177
184
  const values = new Set<number>();
178
185
  for (const part of field.split(',')) {
179
186
  if (part === '') throw cronInvalid(expression, `empty list item in "${field}"`);
180
- const [rangePart, stepPart] = part.split('/');
181
- if (rangePart === undefined || (part.includes('/') && stepPart === undefined)) {
187
+ // Destructuring the split read the first two pieces and dropped the rest, so `1/2/3` was
188
+ // `1/2` and `1-5-7` was `1-5`: a typo that validated and scheduled something else.
189
+ const stepParts = part.split('/');
190
+ const [rangePart, stepPart] = stepParts;
191
+ if (rangePart === undefined || stepParts.length > 2) {
182
192
  throw cronInvalid(expression, `malformed step in "${part}"`);
183
193
  }
184
194
  const step = stepPart === undefined ? 1 : parseInteger(stepPart);
@@ -192,7 +202,11 @@ function parseField(
192
202
  from = min;
193
203
  to = max;
194
204
  } else if (rangePart.includes('-')) {
195
- const [left, right] = rangePart.split('-');
205
+ const ends = rangePart.split('-');
206
+ if (ends.length !== 2) {
207
+ throw cronInvalid(expression, `a range has two ends, got ${ends.length} in "${part}"`);
208
+ }
209
+ const [left, right] = ends;
196
210
  from = toNumber(expression, left, min, max, names, nameOffset);
197
211
  to = toNumber(expression, right, min, max, names, nameOffset);
198
212
  } else {
@@ -221,13 +235,13 @@ function toNumber(
221
235
  token: string | undefined,
222
236
  min: number,
223
237
  max: number,
224
- names: readonly string[],
238
+ names: readonly (readonly string[])[],
225
239
  nameOffset: number,
226
240
  ): number {
227
241
  if (token === undefined || token === '') throw cronInvalid(expression, 'missing value');
228
- // Names match on their first three letters, so `mon` and `monday` both work — but a token
229
- // carrying anything that is not a letter is a typo, not a name, and falls through to digits.
230
- const named = /^[a-z]+$/.test(token) ? names.indexOf(token.slice(0, 3)) : -1;
242
+ // A name is its three-letter form or its whole word — `mon` and `monday` — matched EXACTLY.
243
+ // Matching on the first three letters read `marzipan` as March and `monkey` as Monday.
244
+ const named = names.reduce((found, list) => (found === -1 ? list.indexOf(token) : found), -1);
231
245
  const value = named === -1 ? parseInteger(token) : named + nameOffset;
232
246
  if (value === undefined) {
233
247
  throw cronInvalid(expression, `"${token}" is not a number or a name`);