@ultimat3/time 22.15.0 → 24.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 +22 -0
- package/README.md +24 -3
- package/package.json +4 -3
- package/src/business.ts +50 -11
- package/src/context.ts +4 -3
- package/src/cron-occurrence.ts +81 -6
- package/src/cron-parse.ts +24 -10
- package/src/duration.ts +6 -2
- package/src/errors.ts +4 -2
- package/src/format.ts +53 -17
- package/src/index.ts +1 -0
- package/src/plain-date.ts +25 -5
- package/src/zone-canonical.ts +5 -0
- package/src/zoned.ts +29 -2
package/CLAUDE.md
CHANGED
|
@@ -143,6 +143,28 @@
|
|
|
143
143
|
applies ONLY when day-of-month is restricted and day-of-week is not: Vixie's OR means
|
|
144
144
|
`0 0 30 2 5` fires every Friday in February, so refusing it would break a working schedule.
|
|
145
145
|
`MAX_STEPS` stays as the backstop for what the check cannot see.
|
|
146
|
+
- **The repeated hour of a fall-back night is run through by an INTERVAL and fired once by a FIXED
|
|
147
|
+
time, `As of 2026-10`.** `CronExpression.wildcardTime` — the minute or hour field spelled `*` or
|
|
148
|
+
`*/n`, Vixie's test — is the whole decision. The wall-clock walk alone cannot see the hour come
|
|
149
|
+
round again (from 02:55 it reaches 03:00), so `*/5 * * * *` went dark 01:00Z–02:00Z in Berlin
|
|
150
|
+
once a year while `matchesCron` answered `true` inside it. `nextCronOccurrence` walks a second
|
|
151
|
+
time from the wall clock the transition lands on. A fixed time never returns its second pass,
|
|
152
|
+
from ANY `after` — `@ultimat3/jobs`' `latestOccurrence` bisects over `after`, so the answer must
|
|
153
|
+
be monotone in it; `cron-occurrence.test.ts` pins that. `matchesCron` stays the pure wall-clock
|
|
154
|
+
predicate and is `true` for a fixed time's second pass: it answers "does this instant's clock
|
|
155
|
+
match", not "is this an occurrence".
|
|
156
|
+
- **`addBusinessDays` builds every candidate from the calendar date + the ORIGINAL wall time.**
|
|
157
|
+
Chained through `addDaysInZone`, a 02:30 that crossed a spring-forward Sunday became 03:30 and
|
|
158
|
+
stayed 03:30 on Monday. `businessDaysBetween` walks the same dates (`localDay`): a chain steps
|
|
159
|
+
OVER a date the zone never had (Samoa, 2011-12-30) and so counted the day `to` excludes. A date
|
|
160
|
+
that does not exist is not a day of either kind.
|
|
161
|
+
- **`formatRelative` takes `zone`, and from a day apart counts CALENDAR days** (`daysBetween`).
|
|
162
|
+
`trunc(47h / 24h)` called two midnights away "tomorrow". Under a day it is elapsed time; a full
|
|
163
|
+
day apart with no midnight crossed (a 25-hour day) is hours, not "today". **Breaking.**
|
|
164
|
+
- **A cron name matches EXACTLY** — its three letters or its whole word. First-three-letters
|
|
165
|
+
matching read `marzipan` as March. Extra `-` and `/` parts are refused, not dropped.
|
|
166
|
+
- **A `PlainDate` has four year digits.** `plainDateUtc` (and so `addPlainDays`) and `plainDateIn`
|
|
167
|
+
refuse a year outside 0000-9999 rather than brand `10000-01-01`, which `isPlainDate` rejects.
|
|
146
168
|
- Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
|
|
147
169
|
- Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
|
|
148
170
|
- 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,
|
|
@@ -126,7 +132,18 @@ through `JSON.stringify` as itself, and is the literal Postgres accepts and retu
|
|
|
126
132
|
macros, and Vixie's dom/dow OR rule. `nextCronOccurrence(expr, zone, after)` iterates the
|
|
127
133
|
zone's **wall clock**, so `0 3 * * *` in `Europe/Berlin` stays at 03:00 local across a DST
|
|
128
134
|
change, and a job scheduled inside the gap runs at the first existing local time instead of
|
|
129
|
-
being skipped.
|
|
135
|
+
being skipped. A name is its three letters or its whole word (`mon`, `monday`) — never a prefix
|
|
136
|
+
match — and a range has two ends, a step one: `marzipan`, `1-5-7` and `1/2/3` are `X_CRON_INVALID`.
|
|
137
|
+
|
|
138
|
+
On a fall-back night the repeated hour is decided by what the expression names
|
|
139
|
+
(`CronExpression.wildcardTime`, Vixie's rule):
|
|
140
|
+
|
|
141
|
+
| Expression | In the repeated hour |
|
|
142
|
+
|---|---|
|
|
143
|
+
| a fixed time — `30 2 * * *` | fires **once**, on the first pass, whichever instant it is asked from |
|
|
144
|
+
| an interval — minute or hour field `*` or `*/n`: `*/5 * * * *`, `0 * * * *` | runs through **both** passes; no hour of the night is skipped |
|
|
145
|
+
|
|
146
|
+
`describeCron(expr, locale, phrases)` renders the dashboard summary — month and
|
|
130
147
|
weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
|
|
131
148
|
because tier 1 cannot reach `t()` and a built-in default would ship English to every locale. A
|
|
132
149
|
long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
|
|
@@ -139,6 +156,10 @@ ten-second step used to render as "every minute".
|
|
|
139
156
|
The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
|
|
140
157
|
`WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
|
|
141
158
|
|
|
159
|
+
`addBusinessDays(at, days, calendar)` keeps the **original** wall-clock time: every candidate is the
|
|
160
|
+
calendar date plus the time the caller started with, so a spring-forward day on the way does not
|
|
161
|
+
shift the days after it.
|
|
162
|
+
|
|
142
163
|
`businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
|
|
143
164
|
days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
|
|
144
165
|
neither endpoint's wall-clock time is part of the question. A reversed range is the same count
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"!src/**/*.test.ts",
|
|
25
|
+
"!src/**/*-fixture.ts",
|
|
25
26
|
"CLAUDE.md",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"test": "bun test"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/core": "24.0.0",
|
|
39
|
+
"@ultimat3/schema": "24.0.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/src/business.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { scheduleInvalid } from './errors';
|
|
7
7
|
import type { Instant } from './instant';
|
|
8
|
-
import {
|
|
9
|
-
import type
|
|
8
|
+
import { daysBetween, fromZoned, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
|
|
9
|
+
import { type TimeZone, utcEpoch } from './zones';
|
|
10
10
|
|
|
11
11
|
/** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
|
|
12
12
|
export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
@@ -57,18 +57,56 @@ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCal
|
|
|
57
57
|
}
|
|
58
58
|
if (days === 0) return at;
|
|
59
59
|
const step = days > 0 ? 1 : -1;
|
|
60
|
+
const origin = toZoned(at, calendar.zone);
|
|
60
61
|
let remaining = Math.abs(days);
|
|
61
|
-
let
|
|
62
|
+
let offset = 0;
|
|
62
63
|
let guard = 0;
|
|
64
|
+
let landed = at;
|
|
63
65
|
|
|
64
66
|
while (remaining > 0) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
offset += step;
|
|
68
|
+
const candidate = localDay(origin, offset, calendar.zone);
|
|
69
|
+
if (candidate !== null && isBusinessDay(candidate, calendar)) {
|
|
70
|
+
remaining -= 1;
|
|
71
|
+
landed = candidate;
|
|
72
|
+
}
|
|
67
73
|
guard += 1;
|
|
68
74
|
// A calendar with every day marked as a holiday would otherwise loop forever.
|
|
69
|
-
if (guard > Math.abs(days) * 7 + 3650) return
|
|
75
|
+
if (guard > Math.abs(days) * 7 + 3650) return localDay(origin, offset, calendar.zone) ?? landed;
|
|
70
76
|
}
|
|
71
|
-
return
|
|
77
|
+
return landed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The calendar date `offset` days from `origin`'s, at `origin`'s wall time — or `null` when the
|
|
82
|
+
* zone never had that date (Samoa's 2011-12-30).
|
|
83
|
+
*
|
|
84
|
+
* Built from the calendar date and the ORIGINAL wall time, never from the day before it. Chaining
|
|
85
|
+
* `addDaysInZone` a day at a time carried a DST repair forward: 02:30 through a spring-forward
|
|
86
|
+
* Sunday became 03:30 there and stayed 03:30 on Monday, where 02:30 exists. `fromZoned` normalizes
|
|
87
|
+
* the day overflow, so `day + offset` is the whole walk.
|
|
88
|
+
*/
|
|
89
|
+
function localDay(origin: ZonedDateTime, offset: number, zone: TimeZone): Instant | null {
|
|
90
|
+
const candidate = fromZoned(
|
|
91
|
+
{
|
|
92
|
+
year: origin.year,
|
|
93
|
+
month: origin.month,
|
|
94
|
+
day: origin.day + offset,
|
|
95
|
+
hour: origin.hour,
|
|
96
|
+
minute: origin.minute,
|
|
97
|
+
second: origin.second,
|
|
98
|
+
millisecond: origin.millisecond,
|
|
99
|
+
},
|
|
100
|
+
zone,
|
|
101
|
+
{ gap: 'next' },
|
|
102
|
+
);
|
|
103
|
+
const wanted = new Date(utcEpoch(origin.year, origin.month, origin.day + offset));
|
|
104
|
+
const got = toZoned(candidate, zone);
|
|
105
|
+
const exists =
|
|
106
|
+
got.year === wanted.getUTCFullYear() &&
|
|
107
|
+
got.month === wanted.getUTCMonth() + 1 &&
|
|
108
|
+
got.day === wanted.getUTCDate();
|
|
109
|
+
return exists ? candidate : null;
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
/** The next business day at the same local time; today if it already qualifies. */
|
|
@@ -97,13 +135,14 @@ export function businessDaysBetween(
|
|
|
97
135
|
const start = sign === 1 ? from : to;
|
|
98
136
|
const end = sign === 1 ? to : from;
|
|
99
137
|
// `daysBetween` is the day count of the same half-open interval, so it is also the exact number
|
|
100
|
-
// of iterations
|
|
138
|
+
// of iterations. Each one is a calendar DATE counted from `start`'s — not the instant a day after
|
|
139
|
+
// the previous one, which steps over a date the zone skipped and so reaches past `to`.
|
|
101
140
|
const days = daysBetween(start, end, calendar.zone);
|
|
102
|
-
|
|
141
|
+
const origin = toZoned(start, calendar.zone);
|
|
103
142
|
let count = 0;
|
|
104
143
|
for (let index = 0; index < days; index += 1) {
|
|
105
|
-
|
|
106
|
-
|
|
144
|
+
const day = localDay(origin, index, calendar.zone);
|
|
145
|
+
if (day !== null && isBusinessDay(day, calendar)) count += 1;
|
|
107
146
|
}
|
|
108
147
|
// Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
|
|
109
148
|
// `-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;
|
package/src/cron-occurrence.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type CronExpression, matchesDay, parseCronOnce } from './cron-parse';
|
|
|
7
7
|
import { cronInvalid } from './errors';
|
|
8
8
|
import { fromEpochMs, type Instant } from './instant';
|
|
9
9
|
import { fromZoned, toZoned } from './zoned';
|
|
10
|
-
import { assertTimeZone, type TimeZone, utcEpoch } from './zones';
|
|
10
|
+
import { assertTimeZone, offsetAt, type TimeZone, utcEpoch } from './zones';
|
|
11
11
|
|
|
12
12
|
/** True when `at` matches the expression in `zone`, to the second. */
|
|
13
13
|
export function matchesCron(
|
|
@@ -38,7 +38,15 @@ const MAX_STEPS = 200_000;
|
|
|
38
38
|
* and converted with `fromZoned({ gap: 'next', overlap: 'first' })`:
|
|
39
39
|
* - spring forward: a 02:30 daily job runs once, at the first existing local time after
|
|
40
40
|
* the gap, instead of being silently skipped;
|
|
41
|
-
* - fall back
|
|
41
|
+
* - fall back, a FIXED time (`30 2 * * *`): the repeated local time runs once, on its first
|
|
42
|
+
* occurrence — asked from anywhere, including from inside the repeated hour;
|
|
43
|
+
* - fall back, an INTERVAL (every five minutes, `0 * * * *` — `CronExpression.wildcardTime`):
|
|
44
|
+
* the repeated hour is real elapsed time and the schedule runs through both passes of it.
|
|
45
|
+
*
|
|
46
|
+
* The last two are Vixie cron's rule, and they are one rule about what the author named: a fixed
|
|
47
|
+
* time names a moment of the day, an interval names a rhythm. The wall clock alone could not say
|
|
48
|
+
* it — walking it forward from 02:55 reaches 03:00 and never sees 02:00 come round again, so
|
|
49
|
+
* every interval schedule went dark for one hour a year.
|
|
42
50
|
*/
|
|
43
51
|
export function nextCronOccurrence(
|
|
44
52
|
expression: string | CronExpression,
|
|
@@ -47,16 +55,77 @@ export function nextCronOccurrence(
|
|
|
47
55
|
): Instant {
|
|
48
56
|
const cron = parseCronOnce(expression);
|
|
49
57
|
assertTimeZone(zone);
|
|
58
|
+
const source = typeof expression === 'string' ? expression : cron.source;
|
|
50
59
|
|
|
51
60
|
const start = toZoned(after, zone);
|
|
52
|
-
const
|
|
61
|
+
const first = walk(cron, zone, source, after, {
|
|
53
62
|
year: start.year,
|
|
54
63
|
month: start.month,
|
|
55
64
|
day: start.day,
|
|
56
65
|
hour: start.hour,
|
|
57
66
|
minute: start.minute,
|
|
58
67
|
second: start.second + 1,
|
|
59
|
-
};
|
|
68
|
+
});
|
|
69
|
+
if (!cron.wildcardTime) return first;
|
|
70
|
+
|
|
71
|
+
// The second pass of an overlap that begins AFTER `after`. Its wall times are behind the cursor
|
|
72
|
+
// the first walk started from, so that walk cannot have met them; they are met here, by walking
|
|
73
|
+
// again from the wall clock the transition lands on. Only a transition inside the overlap's own
|
|
74
|
+
// reach matters — past it, every repeated wall time has a first-pass instant that is itself
|
|
75
|
+
// after `after`, and the first walk already answered with something at or before it.
|
|
76
|
+
const horizon = Math.min(first.getTime(), after.getTime() + OVERLAP_REACH_MS);
|
|
77
|
+
const offsetBefore = offsetAt(zone, after);
|
|
78
|
+
if (offsetAt(zone, fromEpochMs(horizon)) >= offsetBefore) return first;
|
|
79
|
+
const landed = toZoned(fromEpochMs(fallBackAt(zone, after.getTime(), horizon)), zone);
|
|
80
|
+
const repeated = walk(
|
|
81
|
+
cron,
|
|
82
|
+
zone,
|
|
83
|
+
source,
|
|
84
|
+
after,
|
|
85
|
+
{
|
|
86
|
+
year: landed.year,
|
|
87
|
+
month: landed.month,
|
|
88
|
+
day: landed.day,
|
|
89
|
+
hour: landed.hour,
|
|
90
|
+
minute: landed.minute,
|
|
91
|
+
second: landed.second,
|
|
92
|
+
},
|
|
93
|
+
'second',
|
|
94
|
+
);
|
|
95
|
+
return repeated.getTime() < first.getTime() ? repeated : first;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How far ahead of `after` a fall-back can begin and still repeat a wall time `after` has already
|
|
100
|
+
* passed. A day and a bit: no zone's clock has ever been set back by more than a day.
|
|
101
|
+
*/
|
|
102
|
+
const OVERLAP_REACH_MS = 26 * 3_600_000;
|
|
103
|
+
|
|
104
|
+
/** The first epoch ms in `(from, to]` whose offset is below `from`'s — found by bisection. */
|
|
105
|
+
function fallBackAt(zone: TimeZone, from: number, to: number): number {
|
|
106
|
+
const before = offsetAt(zone, fromEpochMs(from));
|
|
107
|
+
let lo = from;
|
|
108
|
+
let hi = to;
|
|
109
|
+
while (hi - lo > 1) {
|
|
110
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
111
|
+
if (offsetAt(zone, fromEpochMs(mid)) < before) hi = mid;
|
|
112
|
+
else lo = mid;
|
|
113
|
+
}
|
|
114
|
+
return hi;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk the wall clock forward from `cursor` (inclusive) to the first matching wall time whose
|
|
119
|
+
* instant is after `after`. `overlap` is which pass a repeated wall time resolves to FIRST.
|
|
120
|
+
*/
|
|
121
|
+
function walk(
|
|
122
|
+
cron: CronExpression,
|
|
123
|
+
zone: TimeZone,
|
|
124
|
+
source: string,
|
|
125
|
+
after: Instant,
|
|
126
|
+
cursor: Cursor,
|
|
127
|
+
overlap: 'first' | 'second' = 'first',
|
|
128
|
+
): Instant {
|
|
60
129
|
carry(cursor);
|
|
61
130
|
|
|
62
131
|
for (let step = 0; step < MAX_STEPS; step += 1) {
|
|
@@ -99,8 +168,14 @@ export function nextCronOccurrence(
|
|
|
99
168
|
continue;
|
|
100
169
|
}
|
|
101
170
|
|
|
102
|
-
const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap
|
|
171
|
+
const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap });
|
|
103
172
|
if (candidate.getTime() > after.getTime()) return candidate;
|
|
173
|
+
// `after` is INSIDE the second pass of a repeated hour, so the first pass of this wall time is
|
|
174
|
+
// behind it. An interval schedule's next run is the second pass; a fixed time already ran.
|
|
175
|
+
if (cron.wildcardTime && overlap === 'first') {
|
|
176
|
+
const later = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'second' });
|
|
177
|
+
if (later.getTime() > after.getTime()) return later;
|
|
178
|
+
}
|
|
104
179
|
// The DST gap can push a candidate onto an instant we have already passed; step on.
|
|
105
180
|
cursor.second += 1;
|
|
106
181
|
carry(cursor);
|
|
@@ -110,7 +185,7 @@ export function nextCronOccurrence(
|
|
|
110
185
|
// February — is refused by `parseCron` in constant time, because reaching it here cost ~150ms of
|
|
111
186
|
// blocking CPU per call and `firedSince` pays that per tick of the scheduler's leader loop.
|
|
112
187
|
throw cronInvalid(
|
|
113
|
-
|
|
188
|
+
source,
|
|
114
189
|
`no occurrence after ${MAX_STEPS} search steps — the date fields can never all match`,
|
|
115
190
|
);
|
|
116
191
|
}
|
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`);
|
package/src/duration.ts
CHANGED
|
@@ -152,7 +152,9 @@ export function formatDuration(
|
|
|
152
152
|
// Screened once, here, rather than at each of the three `Intl` constructions below — and before
|
|
153
153
|
// any of them, so a malformed tag is one refusal and never a partially built string.
|
|
154
154
|
const tag = assertLocale(locale);
|
|
155
|
-
|
|
155
|
+
// `NaN` rendered as "NaN days, NaN hr" — every `Math.floor` below propagates it and `Intl`
|
|
156
|
+
// formats it. `finiteOption`, as `toMs`: a duration is legitimately negative and fractional.
|
|
157
|
+
let remaining = Math.abs(Math.round(finiteOption('formatDuration', 'ms', ms)));
|
|
156
158
|
const pieces: string[] = [];
|
|
157
159
|
|
|
158
160
|
for (const [unit, scale] of FORMAT_UNITS) {
|
|
@@ -179,7 +181,9 @@ export function formatDuration(
|
|
|
179
181
|
|
|
180
182
|
/** `PT2H30M` — for OpenAPI schemas and cron metadata. */
|
|
181
183
|
export function formatDurationIso(ms: number): string {
|
|
182
|
-
|
|
184
|
+
// Screened for the reason `formatDuration` is: `NaN` fails every `> 0` below and came out as
|
|
185
|
+
// `P0D`, a well-formed zero for a duration nobody computed.
|
|
186
|
+
const total = Math.abs(Math.round(finiteOption('formatDurationIso', 'ms', ms)));
|
|
183
187
|
const days = Math.floor(total / DAY);
|
|
184
188
|
const hours = Math.floor((total % DAY) / HOUR);
|
|
185
189
|
const minutes = Math.floor((total % HOUR) / MINUTE);
|
package/src/errors.ts
CHANGED
|
@@ -69,10 +69,12 @@ export function scheduleInvalid(field: string, value: unknown, range: string): T
|
|
|
69
69
|
* abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label
|
|
70
70
|
* link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction.
|
|
71
71
|
*/
|
|
72
|
-
export function timezoneInvalid(zone:
|
|
72
|
+
export function timezoneInvalid(zone: unknown): TimeError {
|
|
73
73
|
return new TimeError({
|
|
74
74
|
code: 'X_TIMEZONE_INVALID',
|
|
75
|
-
|
|
75
|
+
// `unknown`, because the refusal is reached by exactly the callers the `string` type did not
|
|
76
|
+
// stop — an omitted `zone` is `undefined` here. A string keeps its quoted spelling.
|
|
77
|
+
cause: `${typeof zone === 'string' ? `"${zone}"` : renderCauseValue(zone)} is not an IANA Area/Location zone name`,
|
|
76
78
|
fix: "use Area/Location, or UTC. A single-label legacy name swaps mechanically — Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC. An abbreviation or a numeric offset does not: CET and EST5EDT name no jurisdiction and carry no DST rule, so name the city whose clock you mean (Europe/Paris, America/New_York). Every accepted name: Intl.supportedValuesOf('timeZone')",
|
|
77
79
|
});
|
|
78
80
|
}
|
package/src/format.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { assertLocale, cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
import { differenceMs, type Instant } from './instant';
|
|
9
|
-
import { isoDateInZone } from './zoned';
|
|
9
|
+
import { daysBetween, isoDateInZone } from './zoned';
|
|
10
10
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
11
11
|
|
|
12
12
|
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
|
@@ -27,9 +27,10 @@ export interface FormatDateTimeOptions extends FormatContext {
|
|
|
27
27
|
|
|
28
28
|
/** `14 Mar 2026, 09:00` in `en-GB` / `Europe/Berlin`. */
|
|
29
29
|
export function formatDateTime(at: Instant, options: FormatDateTimeOptions): string {
|
|
30
|
+
const zone = zoneOf(options);
|
|
30
31
|
const style = options.style ?? 'medium';
|
|
31
32
|
return formatterFor(options.locale, {
|
|
32
|
-
timeZone:
|
|
33
|
+
timeZone: zone,
|
|
33
34
|
dateStyle: options.dateStyle ?? style,
|
|
34
35
|
timeStyle: options.timeStyle ?? (style === 'full' || style === 'long' ? 'medium' : style),
|
|
35
36
|
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
@@ -40,8 +41,9 @@ export function formatDate(
|
|
|
40
41
|
at: Instant,
|
|
41
42
|
options: FormatContext & { style?: DateTimeStyle },
|
|
42
43
|
): string {
|
|
44
|
+
const zone = zoneOf(options);
|
|
43
45
|
return formatterFor(options.locale, {
|
|
44
|
-
timeZone:
|
|
46
|
+
timeZone: zone,
|
|
45
47
|
dateStyle: options.style ?? 'medium',
|
|
46
48
|
}).format(at);
|
|
47
49
|
}
|
|
@@ -50,8 +52,9 @@ export function formatTime(
|
|
|
50
52
|
at: Instant,
|
|
51
53
|
options: FormatContext & { style?: DateTimeStyle; hour12?: boolean },
|
|
52
54
|
): string {
|
|
55
|
+
const zone = zoneOf(options);
|
|
53
56
|
return formatterFor(options.locale, {
|
|
54
|
-
timeZone:
|
|
57
|
+
timeZone: zone,
|
|
55
58
|
timeStyle: options.style ?? 'short',
|
|
56
59
|
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
57
60
|
}).format(at);
|
|
@@ -63,11 +66,12 @@ export function formatTime(
|
|
|
63
66
|
* in a fixed position instead of wherever the locale pattern happens to put it.
|
|
64
67
|
*/
|
|
65
68
|
export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): string {
|
|
69
|
+
const zone = zoneOf(options);
|
|
66
70
|
const style = options.style ?? 'medium';
|
|
67
71
|
// `timeZoneName` is a component option, and Intl forbids mixing those with dateStyle /
|
|
68
72
|
// timeStyle — so the components are spelled out here instead.
|
|
69
73
|
const parts = formatterFor(options.locale, {
|
|
70
|
-
timeZone:
|
|
74
|
+
timeZone: zone,
|
|
71
75
|
year: 'numeric',
|
|
72
76
|
month: style === 'short' ? 'numeric' : style === 'medium' ? 'short' : 'long',
|
|
73
77
|
day: 'numeric',
|
|
@@ -100,44 +104,66 @@ export function formatIsoDate(at: Instant, zone: TimeZone): string {
|
|
|
100
104
|
return isoDateInZone(at, assertTimeZone(zone));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export interface FormatRelativeOptions extends
|
|
107
|
+
export interface FormatRelativeOptions extends FormatContext {
|
|
104
108
|
/** The reference point. Pass `now(clock)` — never let this default to a live clock. */
|
|
105
109
|
now: Instant;
|
|
106
110
|
numeric?: 'always' | 'auto';
|
|
107
111
|
style?: 'long' | 'short' | 'narrow';
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
['day', 86_400_000],
|
|
114
|
+
const DAY_MS = 86_400_000;
|
|
115
|
+
|
|
116
|
+
/** Under a day apart: elapsed time, largest unit first. */
|
|
117
|
+
const CLOCK_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
115
118
|
['hour', 3_600_000],
|
|
116
119
|
['minute', 60_000],
|
|
117
120
|
['second', 1000],
|
|
118
121
|
];
|
|
119
122
|
|
|
120
|
-
/**
|
|
123
|
+
/** A day or more apart: calendar days in the zone, largest unit first. */
|
|
124
|
+
const CALENDAR_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
125
|
+
['year', 365],
|
|
126
|
+
['month', 30],
|
|
127
|
+
['week', 7],
|
|
128
|
+
['day', 1],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `in 3 days` / `2 hours ago`, picking the largest unit that fits.
|
|
133
|
+
*
|
|
134
|
+
* From a day apart, the number is CALENDAR days in `zone` — local midnights crossed, the count
|
|
135
|
+
* `daysBetween` gives — never elapsed milliseconds truncated. "Tomorrow" is a statement about the
|
|
136
|
+
* calendar: 47 hours ahead is two midnights away, and `trunc(47 / 24)` called it tomorrow. That is
|
|
137
|
+
* why this takes a zone like every other formatter here; which day an instant falls on has no
|
|
138
|
+
* answer without one.
|
|
139
|
+
*/
|
|
121
140
|
export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
|
|
141
|
+
const zone = zoneOf(options);
|
|
122
142
|
const delta = differenceMs(options.now, at);
|
|
123
143
|
const formatter = new Intl.RelativeTimeFormat(assertLocale(options.locale), {
|
|
124
144
|
numeric: options.numeric ?? 'auto',
|
|
125
145
|
style: options.style ?? 'long',
|
|
126
146
|
});
|
|
127
147
|
const magnitude = Math.abs(delta);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// `days === 0` a full day apart is 24 real hours inside one 25-hour local day: no midnight was
|
|
149
|
+
// crossed, so it is hours — "today" would be the calendar's answer to a question about elapsed
|
|
150
|
+
// time.
|
|
151
|
+
const days = magnitude >= DAY_MS ? daysBetween(options.now, at, zone) : 0;
|
|
152
|
+
for (const [unit, size] of CALENDAR_UNITS) {
|
|
153
|
+
if (Math.abs(days) >= size) return formatter.format(Math.trunc(days / size), unit);
|
|
154
|
+
}
|
|
155
|
+
for (const [unit, ms] of CLOCK_UNITS) {
|
|
156
|
+
if (magnitude >= ms) return formatter.format(Math.trunc(delta / ms), unit);
|
|
132
157
|
}
|
|
133
158
|
return formatter.format(0, 'second');
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
/** `14–16 Mar 2026` — one call, so the locale decides how to collapse the range. */
|
|
137
162
|
export function formatRange(from: Instant, to: Instant, options: FormatDateTimeOptions): string {
|
|
163
|
+
const zone = zoneOf(options);
|
|
138
164
|
const style = options.style ?? 'medium';
|
|
139
165
|
const formatter = formatterFor(options.locale, {
|
|
140
|
-
timeZone:
|
|
166
|
+
timeZone: zone,
|
|
141
167
|
dateStyle: options.dateStyle ?? style,
|
|
142
168
|
...(options.timeStyle === undefined ? {} : { timeStyle: options.timeStyle }),
|
|
143
169
|
}) as Intl.DateTimeFormat & {
|
|
@@ -173,6 +199,16 @@ export function ordinal(value: number): string {
|
|
|
173
199
|
return `${value}${ORDINAL_SUFFIX[category]}`;
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The options' zone, canonical — read FIRST in every formatter, and through `?.`: a call from
|
|
204
|
+
* untyped code with the options object missing is the same mistake as one with `zone` missing, and
|
|
205
|
+
* both are `X_TIMEZONE_INVALID` rather than a bare `TypeError` on the first property read.
|
|
206
|
+
*/
|
|
207
|
+
function zoneOf(options: { readonly zone: TimeZone }): TimeZone {
|
|
208
|
+
const loose: { readonly zone?: unknown } | null | undefined = options;
|
|
209
|
+
return assertTimeZone(loose?.zone as TimeZone);
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
const cache = new Map<string, Intl.DateTimeFormat>();
|
|
177
213
|
|
|
178
214
|
/**
|
package/src/index.ts
CHANGED
package/src/plain-date.ts
CHANGED
|
@@ -91,10 +91,20 @@ export function plainDateParts(date: PlainDate): PlainDateParts {
|
|
|
91
91
|
/**
|
|
92
92
|
* The calendar date an instant falls on **in a named zone** — the one conversion between the two,
|
|
93
93
|
* and it takes a zone because there is no other honest way to make it. 09:00 UTC on the 14th is
|
|
94
|
-
* still the 13th in Los Angeles.
|
|
94
|
+
* still the 13th in Los Angeles. A local date outside years 0000-9999 is refused, as `plainDateUtc`
|
|
95
|
+
* refuses one.
|
|
95
96
|
*/
|
|
96
|
-
export
|
|
97
|
-
isoDateInZone(at, zone)
|
|
97
|
+
export function plainDateIn(at: Instant, zone: TimeZone): PlainDate {
|
|
98
|
+
const local = isoDateInZone(at, zone);
|
|
99
|
+
// Checked, not cast: an instant late on 9999-12-31 is already year 10000 east of Greenwich, and
|
|
100
|
+
// `isoDateInZone` spells that with five digits — a string `isPlainDate` rejects. `isoDateInZone`
|
|
101
|
+
// itself stays total: it returns a plain string, and `isSameLocalDay` compares two of them for
|
|
102
|
+
// instants in any year a `Date` can hold.
|
|
103
|
+
if (!isPlainDate(local)) {
|
|
104
|
+
throw scheduleInvalid('date', local, 'a calendar date in years 0000-9999');
|
|
105
|
+
}
|
|
106
|
+
return local;
|
|
107
|
+
}
|
|
98
108
|
|
|
99
109
|
/**
|
|
100
110
|
* The calendar date a `Date` holds when read as UTC. For ONE caller: a Postgres driver hands a
|
|
@@ -104,7 +114,14 @@ export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
|
|
|
104
114
|
*/
|
|
105
115
|
export function plainDateUtc(at: Date): PlainDate {
|
|
106
116
|
const checked = instant(at);
|
|
107
|
-
|
|
117
|
+
const year = checked.getUTCFullYear();
|
|
118
|
+
// The form has four year digits and no sign. A `Date` reaches 275760 and -271821, and padding
|
|
119
|
+
// those gave `10000-01-01` branded `PlainDate` — a value `isPlainDate` rejects and a Postgres
|
|
120
|
+
// `date` parameter reads as something else. Refused, so the brand is never a lie.
|
|
121
|
+
if (year < 0 || year > 9999) {
|
|
122
|
+
throw scheduleInvalid('date', checked.toISOString(), 'a calendar date in years 0000-9999');
|
|
123
|
+
}
|
|
124
|
+
return `${pad(year, 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
|
|
108
125
|
checked.getUTCDate(),
|
|
109
126
|
2,
|
|
110
127
|
)}` as PlainDate;
|
|
@@ -120,7 +137,10 @@ export const comparePlainDates = (left: PlainDate, right: PlainDate): number =>
|
|
|
120
137
|
|
|
121
138
|
const DAY_MS = 86_400_000;
|
|
122
139
|
|
|
123
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. A
|
|
142
|
+
* result outside years 0000-9999 is refused by `plainDateUtc` rather than branded.
|
|
143
|
+
*/
|
|
124
144
|
export function addPlainDays(date: PlainDate, days: number): PlainDate {
|
|
125
145
|
if (!Number.isSafeInteger(days)) {
|
|
126
146
|
throw scheduleInvalid('days', days, 'a whole number of days');
|
package/src/zone-canonical.ts
CHANGED
|
@@ -37,6 +37,11 @@ const probed = new Map<string, string | ''>();
|
|
|
37
37
|
* `'CET'` and `'+01:00'` are not zones: an abbreviation is ambiguous and an offset has no rules.
|
|
38
38
|
*/
|
|
39
39
|
export function canonicalTimeZone(zone: string): string | undefined {
|
|
40
|
+
// The type says `string`; a plain-JS caller, a JSON-driven call and an `as never` do not. Every
|
|
41
|
+
// zoned function in the package funnels through here, so this is the one place a non-string
|
|
42
|
+
// becomes "not a zone" — and so `X_TIMEZONE_INVALID` — rather than a bare `TypeError` out of
|
|
43
|
+
// `zone.toLowerCase()` several frames from the call that omitted it.
|
|
44
|
+
if (typeof zone !== 'string') return undefined;
|
|
40
45
|
if (zone === '' || NUMERIC_OFFSET.test(zone)) return undefined;
|
|
41
46
|
const known = listedZones().get(zone.toLowerCase());
|
|
42
47
|
if (known !== undefined) return known;
|
package/src/zoned.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
* instant. `fromZoned` is the DST-correct one, and it is the reason this package exists.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { dstAmbiguous, dstNonexistent } from './errors';
|
|
6
|
+
import { dstAmbiguous, dstNonexistent, scheduleInvalid } from './errors';
|
|
7
7
|
import { addMs, type Instant } from './instant';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertTimeZone,
|
|
10
|
+
offsetAt,
|
|
11
|
+
offsetLabel,
|
|
12
|
+
type TimeZone,
|
|
13
|
+
utcEpoch,
|
|
14
|
+
zonePartsAt,
|
|
15
|
+
} from './zones';
|
|
9
16
|
|
|
10
17
|
/** A local date and time with no zone attached — meaningless until paired with one. */
|
|
11
18
|
export interface WallClock {
|
|
@@ -168,8 +175,15 @@ export function endOfDay(at: Instant, zone: TimeZone): Instant {
|
|
|
168
175
|
/**
|
|
169
176
|
* Add calendar days in a zone, keeping the wall-clock time. Adding one day is 23, 24 or
|
|
170
177
|
* 25 hours depending on the transition — which is why this is not `+ 86_400_000`.
|
|
178
|
+
*
|
|
179
|
+
* A WHOLE number of days, checked as `addPlainDays` and `addBusinessDays` check theirs: `NaN`
|
|
180
|
+
* reached `Date.UTC` and came back out of `Intl` as a bare `RangeError`, and `0.5` was truncated
|
|
181
|
+
* there and moved nothing — a silent no-op that reads as an answer.
|
|
171
182
|
*/
|
|
172
183
|
export function addDaysInZone(at: Instant, days: number, zone: TimeZone): Instant {
|
|
184
|
+
if (!Number.isSafeInteger(days)) {
|
|
185
|
+
throw scheduleInvalid('days', days, 'a whole number of days');
|
|
186
|
+
}
|
|
173
187
|
const zoned = toZoned(at, zone);
|
|
174
188
|
return fromZoned(
|
|
175
189
|
{
|
|
@@ -192,6 +206,19 @@ export function isoDateInZone(at: Instant, zone: TimeZone): string {
|
|
|
192
206
|
return `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
193
207
|
}
|
|
194
208
|
|
|
209
|
+
/**
|
|
210
|
+
* `2026-03-08T03:00:00-04:00` — ISO-8601 with the ZONE's OWN offset, never collapsed to UTC `Z`
|
|
211
|
+
* (which is what `toIso` is for). The form a schedule is read in: the two passes of a fall-back
|
|
212
|
+
* hour are one wall time and two instants, and the offset is the only thing that tells them
|
|
213
|
+
* apart. Seconds precision; `fromIso` reads it back to the same second.
|
|
214
|
+
*/
|
|
215
|
+
export function isoInZone(at: Instant, zone: TimeZone): string {
|
|
216
|
+
const zoned = toZoned(at, zone);
|
|
217
|
+
const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
218
|
+
const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
|
|
219
|
+
return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
195
222
|
/** True when both instants fall on the same local calendar day. */
|
|
196
223
|
export function isSameLocalDay(left: Instant, right: Instant, zone: TimeZone): boolean {
|
|
197
224
|
return isoDateInZone(left, zone) === isoDateInZone(right, zone);
|