@ultimat3/time 24.0.0 → 25.1.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 -1
- package/README.md +52 -3
- package/package.json +4 -4
- package/src/business.ts +34 -5
- package/src/context.ts +1 -1
- package/src/cron-occurrence.ts +9 -2
- package/src/duration.ts +13 -2
- package/src/errors.ts +25 -3
- package/src/holidays.ts +32 -0
- package/src/index.ts +8 -4
- package/src/months.ts +118 -0
- package/src/plain-date-range.ts +75 -0
- package/src/plain-date.ts +5 -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
|
|
@@ -136,6 +139,8 @@
|
|
|
136
139
|
canonical zero most emitters write — was refused along with `'PT0H0M0S'` and `'P0W'`, while
|
|
137
140
|
`'P0D'` was let through by a special case. `ISO_8601` already requires a component group for any
|
|
138
141
|
body past a bare `'P'`, which is the case the guard was written for.
|
|
142
|
+
- **`parseDuration`'s rounded total must be `Number.isSafeInteger`** (`safeTotal`, both arms) —
|
|
143
|
+
finite components can still sum to `Infinity` or past 2^53. `X_DURATION_INVALID`.
|
|
139
144
|
- **An impossible day/month pair is refused by `parseCron`, in constant time.**
|
|
140
145
|
`isValidCron('0 0 30 2 *')` answered `true` and the refusal arrived ~150 ms later out of
|
|
141
146
|
`nextCronOccurrence`, after 200,000 walk steps — a cost `firedSince` pays on every tick of the
|
|
@@ -165,6 +170,22 @@
|
|
|
165
170
|
matching read `marzipan` as March. Extra `-` and `/` parts are refused, not dropped.
|
|
166
171
|
- **A `PlainDate` has four year digits.** `plainDateUtc` (and so `addPlainDays`) and `plainDateIn`
|
|
167
172
|
refuse a year outside 0000-9999 rather than brand `10000-01-01`, which `isPlainDate` rejects.
|
|
173
|
+
- **Months clamp, and the zoned step is Temporal's `'compatible'`, `As of 2026-10`.**
|
|
174
|
+
`addMonthsInZone` moves the LOCAL date by `addPlainMonths` and resolves the original wall time
|
|
175
|
+
once, `{ gap: 'next', overlap: 'first' }` — forward by the gap, the earlier offset in an overlap.
|
|
176
|
+
Not an option: a second rule is a second answer. `months === 0` returns a copy, because
|
|
177
|
+
re-resolving an overlap's second pass would answer the first. A recurring date is computed from
|
|
178
|
+
its anchor; a clamp does not compose (`months.test.ts` pins both). Its refusals carry the
|
|
179
|
+
corrected call as their fix (`monthsInvalid`) — an out-of-range count names the furthest one
|
|
180
|
+
that lands in 0000-9999.
|
|
181
|
+
- **The holiday `Set` is keyed on the LIST, in a `WeakMap`, and the list is FROZEN when it is
|
|
182
|
+
built.** Keyed on the calendar, a replaced `holidays` would answer stale. An in-place edit would
|
|
183
|
+
too, and detecting one is a pass over the list per lookup — the cost the set removes — so it is
|
|
184
|
+
made impossible: after first use an edit throws `TypeError` at the line that tried it. The WeakMap
|
|
185
|
+
is the bound. `holidays.test.ts` counts builds through a Proxy, so a lost memo is a failing number.
|
|
186
|
+
- **`plainDateRange` refuses a span over `MAX_PLAIN_DATE_RANGE_DAYS` (3660) before allocating**, and
|
|
187
|
+
a reversed range rather than answering it empty. Each refusal is `X_SCHEDULE_INVALID` with its
|
|
188
|
+
OWN fix, the corrected call (`rangeInvalid`), never `scheduleInvalid`'s generic field prose.
|
|
168
189
|
- Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
|
|
169
190
|
- Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
|
|
170
191
|
- Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
|
package/README.md
CHANGED
|
@@ -126,6 +126,36 @@ through `JSON.stringify` as itself, and is the literal Postgres accepts and retu
|
|
|
126
126
|
|
|
127
127
|
`@ultimat3/entity`'s `date()` column is the one that stores it.
|
|
128
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
|
+
|
|
129
159
|
## Cron
|
|
130
160
|
|
|
131
161
|
`parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
|
|
@@ -158,7 +188,26 @@ The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the
|
|
|
158
188
|
|
|
159
189
|
`addBusinessDays(at, days, calendar)` keeps the **original** wall-clock time: every candidate is the
|
|
160
190
|
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.
|
|
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
|
+
```
|
|
162
211
|
|
|
163
212
|
`businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
|
|
164
213
|
days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
|
|
@@ -171,13 +220,13 @@ negated; an empty one is `0` in either direction, never `-0`.
|
|
|
171
220
|
|---|---|
|
|
172
221
|
| `X_TIMEZONE_INVALID` | not `Area/Location` or `UTC`: an abbreviation (`CET`), a numeric offset (`+02:00`), or a single-label legacy name (`Japan`) |
|
|
173
222
|
| `X_CRON_INVALID` | unparseable expression, or one that can never match |
|
|
174
|
-
| `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit |
|
|
223
|
+
| `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit, or a total past `Number.MAX_SAFE_INTEGER` ms (`'99999999999999999999d'`) |
|
|
175
224
|
| `X_DST_AMBIGUOUS` | overlap hit with `overlap: 'throw'` |
|
|
176
225
|
| `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
|
|
177
226
|
| `X_INSTANT_INVALID` | unparseable timestamp |
|
|
178
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 |
|
|
179
228
|
| `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
|
|
180
|
-
| `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 |
|
|
181
230
|
|
|
182
231
|
### Error classes
|
|
183
232
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.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.1.0",
|
|
39
|
+
"@ultimat3/schema": "25.1.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/business.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
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
9
|
import { daysBetween, fromZoned, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
|
|
9
10
|
import { type TimeZone, utcEpoch } from './zones';
|
|
@@ -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,6 +58,11 @@ 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;
|
|
60
67
|
const origin = toZoned(at, calendar.zone);
|
|
61
68
|
let remaining = Math.abs(days);
|
|
@@ -71,12 +78,34 @@ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCal
|
|
|
71
78
|
landed = candidate;
|
|
72
79
|
}
|
|
73
80
|
guard += 1;
|
|
74
|
-
//
|
|
75
|
-
|
|
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);
|
|
76
85
|
}
|
|
77
86
|
return landed;
|
|
78
87
|
}
|
|
79
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
|
+
|
|
80
109
|
/**
|
|
81
110
|
* The calendar date `offset` days from `origin`'s, at `origin`'s wall time — or `null` when the
|
|
82
111
|
* zone never had that date (Samoa's 2011-12-30).
|
package/src/context.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function resolveTimeZone(
|
|
|
101
101
|
* It used to be a `ctx['timeZone']` key nothing in the framework ever set, while the HTTP pipeline
|
|
102
102
|
* wrote `ctx.tz`: two ambient answers to one question, and the one every `@ultimat3/ui` component
|
|
103
103
|
* reads on a server render was the empty one, so every date rendered in UTC however the request
|
|
104
|
-
* arrived. `withChildContext({ tz })` and `
|
|
104
|
+
* arrived. `withChildContext({ tz })` and `ctxOf({ tz })` are therefore the only writers,
|
|
105
105
|
* which is what makes a subtree's zone a core concept rather than this package's.
|
|
106
106
|
*/
|
|
107
107
|
export function currentTimeZone(): TimeZone {
|
package/src/cron-occurrence.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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';
|
|
@@ -190,17 +191,23 @@ function walk(
|
|
|
190
191
|
);
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
/**
|
|
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
|
+
*/
|
|
194
200
|
export function nextCronOccurrences(
|
|
195
201
|
expression: string | CronExpression,
|
|
196
202
|
zone: TimeZone,
|
|
197
203
|
after: Instant,
|
|
198
204
|
count: number,
|
|
199
205
|
): Instant[] {
|
|
206
|
+
const total = finiteCount('nextCronOccurrences', 'count', count, 0);
|
|
200
207
|
const cron = parseCronOnce(expression);
|
|
201
208
|
const results: Instant[] = [];
|
|
202
209
|
let cursor = after;
|
|
203
|
-
for (let index = 0; index <
|
|
210
|
+
for (let index = 0; index < total; index += 1) {
|
|
204
211
|
cursor = nextCronOccurrence(cron, zone, cursor);
|
|
205
212
|
results.push(cursor);
|
|
206
213
|
}
|
package/src/duration.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function parseDuration(input: string): number {
|
|
|
40
40
|
const negative = value.startsWith('-');
|
|
41
41
|
const body = negative ? value.slice(1) : value;
|
|
42
42
|
|
|
43
|
-
if (/^p/i.test(body)) return (
|
|
43
|
+
if (/^p/i.test(body)) return safeTotal(parseIso8601Duration(body, input), negative, input);
|
|
44
44
|
|
|
45
45
|
COMPONENT.lastIndex = 0;
|
|
46
46
|
let total = 0;
|
|
@@ -58,7 +58,18 @@ export function parseDuration(input: string): number {
|
|
|
58
58
|
// Sticky matching starts at 0, so anything short of the end is trailing junk, and a
|
|
59
59
|
// zero-length match means there was no unit at all (`'3'` must not mean 3 of anything).
|
|
60
60
|
if (matched === 0 || matched !== body.length) throw durationInvalid(input);
|
|
61
|
-
return (
|
|
61
|
+
return safeTotal(Math.round(total), negative, input);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Each component is finite and their SUM still need not be: `'9'.repeat(305) + 'd'` is
|
|
66
|
+
* `Infinity`, and twenty nines of days is 8.64e27 — past 2^53, where no timer, `Date` or
|
|
67
|
+
* `wakeAt > now` reads what was written. Refused like any other unreadable duration, on the
|
|
68
|
+
* rounded total of both arms.
|
|
69
|
+
*/
|
|
70
|
+
function safeTotal(ms: number, negative: boolean, input: string): number {
|
|
71
|
+
if (!Number.isSafeInteger(ms)) throw durationInvalid(input);
|
|
72
|
+
return negative ? -ms : ms;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
package/src/errors.ts
CHANGED
|
@@ -53,13 +53,35 @@ export class TimeError extends UltimateError {
|
|
|
53
53
|
/**
|
|
54
54
|
* A wall-clock field outside its range. Separate from `X_DST_*`, which are about times that
|
|
55
55
|
* are legitimately absent or doubled — this one is a spec the caller got wrong.
|
|
56
|
+
*
|
|
57
|
+
* A business calendar with no business day in it (`business.ts`) is the one refusal that is not
|
|
58
|
+
* about a number, and it has two causes with two different repairs, so the caller names which
|
|
59
|
+
* through `field`: `calendar.weekendDays` (the weekend covers the week — shorten it) or
|
|
60
|
+
* `calendar.holidays` (every remaining day is a holiday — remove some). A branch here rather than a
|
|
61
|
+
* second factory, so the code keeps one constructor. Each fix is a call that runs as written; the
|
|
62
|
+
* explanation is the cause's job.
|
|
56
63
|
*/
|
|
57
64
|
export function scheduleInvalid(field: string, value: unknown, range: string): TimeError {
|
|
65
|
+
// `value` is whatever a caller put in a `LocalSlot` — this factory is exported, so it is a form
|
|
66
|
+
// field or a config value as often as it is the `number` the in-package caller passes.
|
|
67
|
+
const cause = `${field} must be ${range}, got ${renderCauseValue(value)}`;
|
|
68
|
+
if (field === 'calendar.weekendDays') {
|
|
69
|
+
return new TimeError({
|
|
70
|
+
code: 'X_SCHEDULE_INVALID',
|
|
71
|
+
cause,
|
|
72
|
+
fix: "addBusinessDays(new Date(), 1, { zone: 'UTC', weekendDays: [6, 7] })",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (field === 'calendar.holidays') {
|
|
76
|
+
return new TimeError({
|
|
77
|
+
code: 'X_SCHEDULE_INVALID',
|
|
78
|
+
cause,
|
|
79
|
+
fix: "addBusinessDays(new Date(), 1, { zone: 'UTC', holidays: [] })",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
58
82
|
return new TimeError({
|
|
59
83
|
code: 'X_SCHEDULE_INVALID',
|
|
60
|
-
|
|
61
|
-
// field or a config value as often as it is the `number` the in-package caller passes.
|
|
62
|
-
cause: `${field} must be ${range}, got ${renderCauseValue(value)}`,
|
|
84
|
+
cause,
|
|
63
85
|
fix: `pass an integer in ${range} for ${field} — wall-clock fields are not wrapped or clamped, because a silently shifted schedule is worse than a failed one`,
|
|
64
86
|
});
|
|
65
87
|
}
|
package/src/holidays.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The holiday lookup behind `isHoliday`: one `Set` per holiday LIST, built on first use. A business
|
|
3
|
+
* calendar is asked about every day it walks, and `includes` on a long closure list made each walk
|
|
4
|
+
* quadratic in the list.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keyed on the list itself, in a `WeakMap`: the cache is bounded by the lists still alive, with no
|
|
9
|
+
* cap to tune and no entry outliving its calendar, and a calendar whose `holidays` is REPLACED
|
|
10
|
+
* misses at once.
|
|
11
|
+
*
|
|
12
|
+
* The list is FROZEN when its set is built, so the two can never disagree. `readonly` is only a
|
|
13
|
+
* type, and an in-place edit (a push, or an element overwritten at the same length) would otherwise
|
|
14
|
+
* be answered from the stale set. Detecting one costs a pass over the list per lookup — the cost
|
|
15
|
+
* the set exists to remove — so the edit is made impossible instead: in strict code (every ES
|
|
16
|
+
* module) it throws a `TypeError` at the line that tried it. Freezing an already-frozen list, or one
|
|
17
|
+
* `plainDateRange` returned, changes nothing.
|
|
18
|
+
*/
|
|
19
|
+
const memo = new WeakMap<readonly string[], ReadonlySet<string>>();
|
|
20
|
+
|
|
21
|
+
const EMPTY: ReadonlySet<string> = new Set<string>();
|
|
22
|
+
|
|
23
|
+
/** The holiday dates of a calendar, as a set — the same set for the same list, always. */
|
|
24
|
+
export function holidaySet(holidays: readonly string[] | undefined): ReadonlySet<string> {
|
|
25
|
+
if (holidays === undefined) return EMPTY;
|
|
26
|
+
const hit = memo.get(holidays);
|
|
27
|
+
if (hit !== undefined) return hit;
|
|
28
|
+
Object.freeze(holidays);
|
|
29
|
+
const set = new Set(holidays);
|
|
30
|
+
memo.set(holidays, set);
|
|
31
|
+
return set;
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/** Public surface of @ultimat3/time. Explicit exports only. */
|
|
2
2
|
|
|
3
3
|
// `localeInvalid` and the `X_LOCALE_INVALID` it carries are `@ultimat3/core`'s since 16.x —
|
|
4
|
-
// `@ultimat3/money` needs the same screen and `money -> time` is a sideways import.
|
|
5
|
-
//
|
|
6
|
-
// `@ultimat3/action` and `@ultimat3/query` re-export core's client-flight names.
|
|
7
|
-
export { localeInvalid } from '@ultimat3/core';
|
|
4
|
+
// `@ultimat3/money` needs the same screen and `money -> time` is a sideways import. Imported from
|
|
5
|
+
// core by every caller; 25.0.0 dropped this package's re-export (`X_HELPER_COPY`).
|
|
8
6
|
export {
|
|
9
7
|
addBusinessDays,
|
|
10
8
|
type BusinessCalendar,
|
|
@@ -101,6 +99,7 @@ export {
|
|
|
101
99
|
toIso,
|
|
102
100
|
toIsoDateUtc,
|
|
103
101
|
} from './instant';
|
|
102
|
+
export { addMonthsInZone, addPlainMonths } from './months';
|
|
104
103
|
export {
|
|
105
104
|
addPlainDays,
|
|
106
105
|
comparePlainDates,
|
|
@@ -116,6 +115,11 @@ export {
|
|
|
116
115
|
plainDateUtc,
|
|
117
116
|
plainDaysBetween,
|
|
118
117
|
} from './plain-date';
|
|
118
|
+
export {
|
|
119
|
+
MAX_PLAIN_DATE_RANGE_DAYS,
|
|
120
|
+
type PlainDateRangeOptions,
|
|
121
|
+
plainDateRange,
|
|
122
|
+
} from './plain-date-range';
|
|
119
123
|
export {
|
|
120
124
|
type LocalSlot,
|
|
121
125
|
nextLocalSlot,
|
package/src/months.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Month arithmetic: the one calendar step with no fixed length. Both functions clamp the day to the
|
|
3
|
+
* target month's last day, and the zoned one does it on the LOCAL date — never UTC month math.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { TimeError } from './errors';
|
|
8
|
+
import { type Instant, instant } from './instant';
|
|
9
|
+
import { daysInMonth, type PlainDate, plainDateOf, plainDateParts } from './plain-date';
|
|
10
|
+
import { fromZoned, toZoned } from './zoned';
|
|
11
|
+
import { assertTimeZone, type TimeZone } from './zones';
|
|
12
|
+
|
|
13
|
+
/** The last month a `PlainDate` can name, counted from year 0's January: 9999-12. */
|
|
14
|
+
const LAST_MONTH = 9999 * 12 + 11;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `date` moved by `months` calendar months, the day clamped to the target month's last day:
|
|
18
|
+
* `2026-01-31 + 1` is `2026-02-28`, `2024-01-31 + 1` is `2024-02-29`, `2026-03-31 − 1` is
|
|
19
|
+
* `2026-02-28`. Negative counts go back; the year rolls either way.
|
|
20
|
+
*
|
|
21
|
+
* A clamp does not compose: `2026-01-31 + 1 + 1` is `2026-03-28`, while `2026-01-31 + 2` is
|
|
22
|
+
* `2026-03-31`. A recurring date (a renewal, a billing day) is computed from its ANCHOR —
|
|
23
|
+
* `addPlainMonths(anchor, n)` for the n-th — never chained from the previous result.
|
|
24
|
+
*
|
|
25
|
+
* A whole number of months, checked as `addPlainDays` checks its days; a result outside years
|
|
26
|
+
* 0000-9999 is refused rather than branded.
|
|
27
|
+
*/
|
|
28
|
+
export function addPlainMonths(date: PlainDate, months: number): PlainDate {
|
|
29
|
+
// Parsed first, so every refusal below can paste the caller's date back as a checked literal.
|
|
30
|
+
const { year, month, day } = plainDateParts(date);
|
|
31
|
+
if (!Number.isSafeInteger(months)) {
|
|
32
|
+
throw monthsInvalid(
|
|
33
|
+
`months must be a whole number of months, got ${renderCauseValue(months)}`,
|
|
34
|
+
`addPlainMonths(${dateLiteral(date)}, ${wholeMonths(months)}) # months is a whole number — round it before the call`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// Months counted from year 0 so one floor-division carries the year in both directions.
|
|
38
|
+
const from = year * 12 + (month - 1);
|
|
39
|
+
const total = from + months;
|
|
40
|
+
if (total < 0 || total > LAST_MONTH) {
|
|
41
|
+
// The furthest count that still lands in 0000-9999, in the direction the caller asked.
|
|
42
|
+
const furthest = total < 0 ? -from : LAST_MONTH - from;
|
|
43
|
+
throw monthsInvalid(
|
|
44
|
+
`${date} ${months < 0 ? '-' : '+'} ${Math.abs(months)} months leaves years 0000-9999`,
|
|
45
|
+
`addPlainMonths(${dateLiteral(date)}, ${furthest}) # the furthest a PlainDate reaches from ${date}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const targetYear = Math.floor(total / 12);
|
|
49
|
+
const targetMonth = total - targetYear * 12 + 1;
|
|
50
|
+
return plainDateOf({
|
|
51
|
+
year: targetYear,
|
|
52
|
+
month: targetMonth,
|
|
53
|
+
day: Math.min(day, daysInMonth(targetYear, targetMonth)),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `at` moved by `months` calendar months in `zone`, keeping the LOCAL wall-clock time: 09:00 EST
|
|
59
|
+
* on Feb 15 + 1 is 09:00 EDT on Mar 15 — 743 hours later, not 744. The local DATE moves by
|
|
60
|
+
* `addPlainMonths` (so it clamps exactly as a plain date does), then the original wall time is
|
|
61
|
+
* resolved on it once. The zone is required: an instant has a month only in a zone.
|
|
62
|
+
*
|
|
63
|
+
* The target wall time can fail to exist, or exist twice. The rule is Temporal's `'compatible'`
|
|
64
|
+
* disambiguation, and it is not configurable:
|
|
65
|
+
*
|
|
66
|
+
* | Target wall time | Resolves to |
|
|
67
|
+
* |---|---|
|
|
68
|
+
* | in a spring-forward GAP | moved FORWARD by the gap's length — 02:30 New York → 03:30 EDT; 02:15 Lord Howe (a 30-minute shift) → 02:45 |
|
|
69
|
+
* | in a fall-back OVERLAP | the EARLIER instant, i.e. the pre-transition offset — 01:30 New York → 01:30 EDT |
|
|
70
|
+
*
|
|
71
|
+
* Those are `fromZoned`'s `{ gap: 'next', overlap: 'first' }`, the policies `addDaysInZone` uses.
|
|
72
|
+
* A caller needing another answer reads the local date from this result and calls `fromZoned` with
|
|
73
|
+
* its own policy. `months === 0` returns a copy of `at` untouched, even inside an overlap's second
|
|
74
|
+
* pass — re-resolving it would answer the first.
|
|
75
|
+
*/
|
|
76
|
+
export function addMonthsInZone(at: Instant, months: number, timeZone: TimeZone): Instant {
|
|
77
|
+
// The zone first: an omitted one is the mistake this signature exists to make loud.
|
|
78
|
+
const zone = assertTimeZone(timeZone);
|
|
79
|
+
if (!Number.isSafeInteger(months)) {
|
|
80
|
+
throw monthsInvalid(
|
|
81
|
+
`months must be a whole number of months, got ${renderCauseValue(months)}`,
|
|
82
|
+
`addMonthsInZone(at, ${wholeMonths(months)}, ${renderFixLiteral(zone, '<Area/Location>')}) # months is a whole number — round it before the call`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (months === 0) return instant(at);
|
|
86
|
+
const local = toZoned(at, zone);
|
|
87
|
+
const target = plainDateParts(
|
|
88
|
+
addPlainMonths(plainDateOf({ year: local.year, month: local.month, day: local.day }), months),
|
|
89
|
+
);
|
|
90
|
+
return fromZoned(
|
|
91
|
+
{
|
|
92
|
+
...target,
|
|
93
|
+
hour: local.hour,
|
|
94
|
+
minute: local.minute,
|
|
95
|
+
second: local.second,
|
|
96
|
+
millisecond: local.millisecond,
|
|
97
|
+
},
|
|
98
|
+
zone,
|
|
99
|
+
{ gap: 'next', overlap: 'first' },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A parsed `PlainDate` — digits and dashes only — as source that pastes as written. */
|
|
104
|
+
const dateLiteral = (date: PlainDate): string =>
|
|
105
|
+
`plainDate(${renderFixLiteral(date, '<YYYY-MM-DD>')})`;
|
|
106
|
+
|
|
107
|
+
/** The whole count nearest what the caller meant, or `1` when there is nothing to round (NaN). */
|
|
108
|
+
const wholeMonths = (months: number): number => {
|
|
109
|
+
const rounded = Math.trunc(months);
|
|
110
|
+
return Number.isSafeInteger(rounded) ? rounded : 1;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `X_SCHEDULE_INVALID` with the corrected CALL as its fix — `scheduleInvalid`'s generic line names a
|
|
115
|
+
* field to repair, which is prose here. `TimeError` takes its fix per throw, so the code is shared.
|
|
116
|
+
*/
|
|
117
|
+
const monthsInvalid = (cause: string, fix: string): TimeError =>
|
|
118
|
+
new TimeError({ code: 'X_SCHEDULE_INVALID', cause, fix });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plainDateRange` — the calendar dates between two `PlainDate`s, as a bounded, frozen array. It is
|
|
3
|
+
* how a closure becomes holidays: `holidays: [...fixed, ...plainDateRange(from, to)]`, one list.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { TimeError } from './errors';
|
|
8
|
+
import { addPlainDays, type PlainDate, plainDate, plainDaysBetween } from './plain-date';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The widest span accepted, in days: ten years and change. A range is materialised, so an absurd
|
|
12
|
+
* one (`0000-01-01 … 9999-12-31`, 3.65 M dates) is refused before it allocates — a holiday list or
|
|
13
|
+
* a report window longer than this is a bug in whatever computed its ends.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_PLAIN_DATE_RANGE_DAYS = 3660;
|
|
16
|
+
|
|
17
|
+
export interface PlainDateRangeOptions {
|
|
18
|
+
/** Leave `end` out — `[start, end)`. Default `false`: `[start, end]`, the closure reading. */
|
|
19
|
+
readonly exclusive?: boolean;
|
|
20
|
+
/** Days between consecutive dates, a positive whole number. Default `1`. */
|
|
21
|
+
readonly stepDays?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every `stepDays`-th date from `start`, up to `end` — **inclusive by default** (`[start, end]`),
|
|
26
|
+
* `[start, end)` with `{ exclusive: true }`. `end` appears only when the step lands on it.
|
|
27
|
+
*
|
|
28
|
+
* An array, not a generator: every caller spreads it into a holiday list or iterates it twice, and
|
|
29
|
+
* the bound makes it small. Frozen: each call's list is its own, not a buffer to edit. A reversed
|
|
30
|
+
* range is refused rather than answered empty — `start > end` is a swapped argument far more often
|
|
31
|
+
* than a deliberate empty set — as is a span over `MAX_PLAIN_DATE_RANGE_DAYS`, whatever the step.
|
|
32
|
+
*/
|
|
33
|
+
export function plainDateRange(
|
|
34
|
+
start: PlainDate,
|
|
35
|
+
end: PlainDate,
|
|
36
|
+
options: PlainDateRangeOptions = {},
|
|
37
|
+
): readonly PlainDate[] {
|
|
38
|
+
const from = plainDate(start);
|
|
39
|
+
const to = plainDate(end);
|
|
40
|
+
const step = options.stepDays ?? 1;
|
|
41
|
+
if (!Number.isSafeInteger(step) || step < 1) {
|
|
42
|
+
throw rangeInvalid(
|
|
43
|
+
`stepDays must be a whole number of days, at least 1, got ${renderCauseValue(step)}`,
|
|
44
|
+
`plainDateRange(${literal(from)}, ${literal(to)}, { stepDays: 1 }) # stepDays is a whole number of days, at least 1`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const span = plainDaysBetween(from, to);
|
|
48
|
+
if (span < 0) {
|
|
49
|
+
throw rangeInvalid(
|
|
50
|
+
`end (${to}) precedes start (${from}) by ${-span} days`,
|
|
51
|
+
`plainDateRange(${literal(to)}, ${literal(from)}) # start first: end must not precede start`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (span > MAX_PLAIN_DATE_RANGE_DAYS) {
|
|
55
|
+
throw rangeInvalid(
|
|
56
|
+
`${from} … ${to} spans ${span} days, more than MAX_PLAIN_DATE_RANGE_DAYS (${MAX_PLAIN_DATE_RANGE_DAYS})`,
|
|
57
|
+
`plainDateRange(${literal(from)}, addPlainDays(${literal(from)}, ${MAX_PLAIN_DATE_RANGE_DAYS})) # at most ${MAX_PLAIN_DATE_RANGE_DAYS} days per range — split a longer span into several`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const last = options.exclusive === true ? span - 1 : span;
|
|
61
|
+
const dates: PlainDate[] = [];
|
|
62
|
+
for (let offset = 0; offset <= last; offset += step) dates.push(addPlainDays(from, offset));
|
|
63
|
+
return Object.freeze(dates);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A checked `PlainDate`'s literal — digits and dashes only, so it pastes into source as written. */
|
|
67
|
+
const literal = (date: PlainDate): string => `plainDate(${renderFixLiteral(date, '<YYYY-MM-DD>')})`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `X_SCHEDULE_INVALID`, with a fix that is the corrected CALL. `scheduleInvalid`'s generic fix
|
|
71
|
+
* names an integer field to repair, which reads as prose here and names nothing this function
|
|
72
|
+
* takes; `TimeError` takes its fix per throw, so the code stays the package's one range refusal.
|
|
73
|
+
*/
|
|
74
|
+
const rangeInvalid = (cause: string, fix: string): TimeError =>
|
|
75
|
+
new TimeError({ code: 'X_SCHEDULE_INVALID', cause, fix });
|
package/src/plain-date.ts
CHANGED
|
@@ -37,8 +37,11 @@ export interface PlainDateParts {
|
|
|
37
37
|
|
|
38
38
|
const pad = (value: number, width: number): string => String(value).padStart(width, '0');
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Days in a month, Gregorian. February is the only interesting one. Exported for `months.ts`'s
|
|
42
|
+
* clamp — one leap-year rule in the package — and deliberately not re-exported by `index.ts`.
|
|
43
|
+
*/
|
|
44
|
+
export const daysInMonth = (year: number, month: number): number =>
|
|
42
45
|
month === 2
|
|
43
46
|
? (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
|
44
47
|
? 29
|
package/src/zones.ts
CHANGED
|
@@ -70,8 +70,13 @@ export function zonePartsAt(zone: TimeZone, at: Instant): ZoneParts {
|
|
|
70
70
|
if (value === undefined) throw timezoneInvalid(zone);
|
|
71
71
|
return Number.parseInt(value, 10);
|
|
72
72
|
};
|
|
73
|
+
// `Intl` writes the proleptic Gregorian ERA year: year 0 is "1 BC", year -5 is "6 BC". The era is
|
|
74
|
+
// read with it and mapped back to the astronomical year every caller computes with (`utcEpoch`,
|
|
75
|
+
// `getUTCFullYear`); without it year 0 read as 1, and every offset before AD 1 was a year off.
|
|
76
|
+
const eraYear = read('year');
|
|
77
|
+
const beforeCommonEra = parts.find((part) => part.type === 'era')?.value === BEFORE_COMMON_ERA;
|
|
73
78
|
return {
|
|
74
|
-
year:
|
|
79
|
+
year: beforeCommonEra ? 1 - eraYear : eraYear,
|
|
75
80
|
month: read('month'),
|
|
76
81
|
day: read('day'),
|
|
77
82
|
hour: read('hour') % 24,
|
|
@@ -157,6 +162,9 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
|
|
|
157
162
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
158
163
|
const labelFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
159
164
|
|
|
165
|
+
/** What the `en-US` formatter below names the era before year 1, at `era: 'short'`. */
|
|
166
|
+
const BEFORE_COMMON_ERA = 'BC';
|
|
167
|
+
|
|
160
168
|
function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
161
169
|
// Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
|
|
162
170
|
const canonical = canonicalTimeZone(zone);
|
|
@@ -168,6 +176,7 @@ function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
|
168
176
|
new Intl.DateTimeFormat('en-US', {
|
|
169
177
|
timeZone: canonical,
|
|
170
178
|
hourCycle: 'h23',
|
|
179
|
+
era: 'short',
|
|
171
180
|
year: 'numeric',
|
|
172
181
|
month: '2-digit',
|
|
173
182
|
day: '2-digit',
|