@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 +42 -1
- package/README.md +74 -4
- package/package.json +4 -4
- package/src/business.ts +83 -15
- package/src/context.ts +5 -4
- package/src/cron-occurrence.ts +90 -8
- package/src/cron-parse.ts +24 -10
- package/src/duration.ts +6 -2
- package/src/errors.ts +29 -5
- package/src/format.ts +53 -17
- package/src/holidays.ts +32 -0
- package/src/index.ts +9 -4
- package/src/months.ts +118 -0
- package/src/plain-date-range.ts +75 -0
- package/src/plain-date.ts +30 -7
- package/src/zone-canonical.ts +5 -0
- package/src/zoned.ts +29 -2
- package/src/zones.ts +10 -1
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: `
|
|
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.
|
|
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": "
|
|
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.
|
|
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": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
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 {
|
|
9
|
-
import type
|
|
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
|
-
|
|
33
|
-
return holidays.
|
|
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
|
|
69
|
+
let offset = 0;
|
|
62
70
|
let guard = 0;
|
|
71
|
+
let landed = at;
|
|
63
72
|
|
|
64
73
|
while (remaining > 0) {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
+
const origin = toZoned(start, calendar.zone);
|
|
103
171
|
let count = 0;
|
|
104
172
|
for (let index = 0; index < days; index += 1) {
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
...
|
|
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 `
|
|
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 {
|
package/src/cron-occurrence.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 <
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
//
|
|
229
|
-
//
|
|
230
|
-
const named =
|
|
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`);
|