@ultimat3/time 1.1.0 → 2.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 +64 -0
- package/README.md +25 -1
- package/package.json +3 -2
- package/src/business.ts +19 -7
- package/src/context.ts +38 -40
- package/src/cron-describe.ts +43 -36
- package/src/cron-parse.ts +8 -3
- package/src/errors.ts +22 -2
- package/src/format.ts +14 -6
- package/src/index.ts +7 -3
- package/src/instant.ts +20 -3
- package/src/intl-cache.ts +26 -0
- package/src/locale-canonical.ts +23 -0
- package/src/schedule.ts +8 -3
- package/src/zone-canonical.ts +54 -0
- package/src/zones.ts +40 -35
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @ultimat3/time — agent notes
|
|
2
|
+
|
|
3
|
+
**Tier 1.** May import `@ultimat3/core`, `@ultimat3/schema`. No external deps — **never add
|
|
4
|
+
`date-fns-tz`**; zone math is `Intl.DateTimeFormat` + `formatToParts`.
|
|
5
|
+
|
|
6
|
+
## Boundary
|
|
7
|
+
|
|
8
|
+
| File | Single responsibility |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `instant.ts` | the UTC `Instant` brand, ISO/epoch conversion, `now(clock)`, `epoch()` |
|
|
11
|
+
| `zones.ts` | IANA validation, `offsetAt` (minutes east), zone labels |
|
|
12
|
+
| `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
|
|
13
|
+
| `locale-canonical.ts` | one locale, one key: `canonicalLocale` — the same collapse for the `Accept-Language` half |
|
|
14
|
+
| `intl-cache.ts` | the one bounded FIFO every `Intl` formatter cache in this package uses |
|
|
15
|
+
| `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
|
|
16
|
+
| `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
|
|
17
|
+
| `duration.ts` | `'2h30m'` ⇄ ms |
|
|
18
|
+
| `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
|
|
19
|
+
| `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
|
|
20
|
+
| `cron-occurrence.ts` | next occurrence, wall-clock driven |
|
|
21
|
+
| `cron-describe.ts` | `describeCron` — `Intl` names, phrases injected. `CronPhrases` is required. |
|
|
22
|
+
| `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
|
|
23
|
+
| `business.ts` | weekends as config, holidays as local dates |
|
|
24
|
+
| `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
- Never format without an explicit `timeZone`. No ambient default, no `toLocaleString()`.
|
|
29
|
+
- **The ambient zone IS `Ctx.tz`**, core's own declared field. This package publishes no writer and
|
|
30
|
+
no field of its own: `createContext({ tz })` and `withChildContext({ tz })` are the way in,
|
|
31
|
+
`currentTimeZone()` the way out. It kept `attachTimeZone`/`timeZoneOf` over `ctx['timeZone']`
|
|
32
|
+
until 1.3.0 — a second ambient store, with **zero** writers, while `@ultimat3/http` wrote `tz` —
|
|
33
|
+
so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render
|
|
34
|
+
formatted in UTC regardless of the zone the caller sent. Two ambient defaults is the worst
|
|
35
|
+
possible version of the rule above. Never reintroduce either half.
|
|
36
|
+
- **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
|
|
37
|
+
a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
|
|
38
|
+
for a locale) and the cache must be bounded (`cachedFormatter`, `intl-cache.ts`). An unbounded
|
|
39
|
+
`Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
|
|
40
|
+
of a 13-letter zone is 2^12. **Both halves, always** — a canonical key does not bound anything
|
|
41
|
+
(an unknown `-u-` extension value survives canonicalization as a distinct string) and the cap
|
|
42
|
+
alone lets one locale evict itself under three spellings.
|
|
43
|
+
- **Never hand back the caller's own `Date`, and never export a shared one.** `Instant` is a
|
|
44
|
+
branded `Date` and a `Date` cannot be frozen — `Object.freeze` does not close `setTime`, the
|
|
45
|
+
value is in an internal slot. So `instant()` copies and `epoch()` is a function, not a constant.
|
|
46
|
+
- **`businessDaysBetween` is `[from, to)` on local calendar dates** — the interval `daysBetween`
|
|
47
|
+
measures, so the two can never disagree. Comparing instants made the answer depend on the
|
|
48
|
+
endpoints' time of day. A new day-counting function states its interval in its header.
|
|
49
|
+
- **`describeCron` declines what it cannot say.** `CronPhrases` is the caller's vocabulary and has
|
|
50
|
+
no seconds phrase, so a 6-field expression with a non-trivial seconds field is
|
|
51
|
+
`X_CRON_NOT_DESCRIBABLE`. Adding a required field to `CronPhrases` would break every caller
|
|
52
|
+
(`packages/cli/src/cmd-tasks.ts` builds one) to describe a schedule almost nobody writes.
|
|
53
|
+
- Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
|
|
54
|
+
- Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
|
|
55
|
+
- Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
|
|
56
|
+
- `m` is minutes, `ms` is milliseconds. A bare number is not a duration.
|
|
57
|
+
- Tests must cover a spring-forward gap, a fall-back overlap and a non-hour offset zone.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
bun test packages/time
|
|
63
|
+
bun run --filter @ultimat3/time typecheck
|
|
64
|
+
```
|
package/README.md
CHANGED
|
@@ -16,6 +16,20 @@ none ever will — "the server's timezone" is not an answer to "what time is it
|
|
|
16
16
|
No `date-fns-tz`, no tzdata table. Zone math is derived from `Intl.DateTimeFormat` +
|
|
17
17
|
`formatToParts`, which is exact in Bun and always current with the runtime's tzdata.
|
|
18
18
|
|
|
19
|
+
**One zone is one key.** `Intl` accepts every casing of an IANA name, so `canonicalTimeZone(z)`
|
|
20
|
+
answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolveTimeZone` both
|
|
21
|
+
return it. Anything reading a zone off a request header should canonicalize before caching on it:
|
|
22
|
+
4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB.
|
|
23
|
+
|
|
24
|
+
One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`,
|
|
25
|
+
`en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they
|
|
26
|
+
reach a formatter cache. The cap in `intl-cache.ts` stays either way: an unknown `-u-` extension
|
|
27
|
+
value survives canonicalization as a distinct string, so the key bounds nothing on its own.
|
|
28
|
+
|
|
29
|
+
Every value this package hands back is its own object: `instant(date)` copies rather than branding
|
|
30
|
+
the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
|
|
31
|
+
mutable `Date` that a single `setUTCFullYear` corrupted for the whole process.
|
|
32
|
+
|
|
19
33
|
## Use
|
|
20
34
|
|
|
21
35
|
```ts
|
|
@@ -73,13 +87,21 @@ change, and a job scheduled inside the gap runs at the first existing local time
|
|
|
73
87
|
being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
|
|
74
88
|
weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
|
|
75
89
|
because tier 1 cannot reach `t()` and a built-in default would ship English to every locale. A
|
|
76
|
-
long clock-time list is capped and the remainder counted with `andMore`, never silently cut.
|
|
90
|
+
long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
|
|
91
|
+
6-field expression whose seconds field says something a 5-field one cannot is **declined** with
|
|
92
|
+
`X_CRON_NOT_DESCRIBABLE` rather than summarised: `CronPhrases` has no seconds vocabulary, so a
|
|
93
|
+
ten-second step used to render as "every minute".
|
|
77
94
|
|
|
78
95
|
## Business days
|
|
79
96
|
|
|
80
97
|
The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
|
|
81
98
|
`WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
|
|
82
99
|
|
|
100
|
+
`businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
|
|
101
|
+
days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
|
|
102
|
+
neither endpoint's wall-clock time is part of the question. A reversed range is the same count
|
|
103
|
+
negated; an empty one is `0` in either direction, never `-0`.
|
|
104
|
+
|
|
83
105
|
## Errors
|
|
84
106
|
|
|
85
107
|
| Code | When |
|
|
@@ -91,6 +113,8 @@ The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the
|
|
|
91
113
|
| `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
|
|
92
114
|
| `X_INSTANT_INVALID` | unparseable timestamp |
|
|
93
115
|
| `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` |
|
|
116
|
+
| `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
|
|
117
|
+
| `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` |
|
|
94
118
|
|
|
95
119
|
## Why it exists
|
|
96
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,6 +31,6 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "2.0.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/business.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Instant } from './instant';
|
|
7
|
-
import { addDaysInZone, isoDateInZone, toZoned } from './zoned';
|
|
7
|
+
import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
|
|
8
8
|
import type { TimeZone } from './zones';
|
|
9
9
|
|
|
10
10
|
/** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
|
|
@@ -68,8 +68,16 @@ export function nextBusinessDay(at: Instant, calendar: BusinessCalendar): Instan
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Business days in `[from, to)
|
|
72
|
-
*
|
|
71
|
+
* Business days in `[from, to)` — **half-open, and counted on local calendar days**: `from`'s own
|
|
72
|
+
* day counts, `to`'s does not, and the wall-clock time of either endpoint is not part of the
|
|
73
|
+
* question. It is exactly the interval `daysBetween` measures, minus the weekends and holidays
|
|
74
|
+
* in it, so the two functions can never disagree about how long a span is.
|
|
75
|
+
*
|
|
76
|
+
* The loop used to advance an *instant* one local day at a time and stop on
|
|
77
|
+
* `cursor.getTime() > end.getTime()`, which made the last day depend on whether `to`'s clock time
|
|
78
|
+
* had passed `from`'s: `Mon 09:00 → Fri 10:00` answered 4 and `Mon 09:00 → Fri 08:00` answered 3
|
|
79
|
+
* for the same calendar span. Order-independent: a reversed range returns a negative count, and an
|
|
80
|
+
* empty one returns `0` in either direction — never `-0`.
|
|
73
81
|
*/
|
|
74
82
|
export function businessDaysBetween(
|
|
75
83
|
from: Instant,
|
|
@@ -79,12 +87,16 @@ export function businessDaysBetween(
|
|
|
79
87
|
const sign = to.getTime() < from.getTime() ? -1 : 1;
|
|
80
88
|
const start = sign === 1 ? from : to;
|
|
81
89
|
const end = sign === 1 ? to : from;
|
|
90
|
+
// `daysBetween` is the day count of the same half-open interval, so it is also the exact number
|
|
91
|
+
// of iterations — the loop cannot run away on a calendar `addDaysInZone` handles oddly.
|
|
92
|
+
const days = daysBetween(start, end, calendar.zone);
|
|
82
93
|
let cursor = start;
|
|
83
94
|
let count = 0;
|
|
84
|
-
|
|
85
|
-
cursor = addDaysInZone(cursor, 1, calendar.zone);
|
|
86
|
-
if (cursor.getTime() > end.getTime()) break;
|
|
95
|
+
for (let index = 0; index < days; index += 1) {
|
|
87
96
|
if (isBusinessDay(cursor, calendar)) count += 1;
|
|
97
|
+
cursor = addDaysInZone(cursor, 1, calendar.zone);
|
|
88
98
|
}
|
|
89
|
-
|
|
99
|
+
// Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
|
|
100
|
+
// `-0`, which `Object.is`, a `Map` key and every `toBe` treat as a value distinct from `0`.
|
|
101
|
+
return count === 0 ? 0 : sign * count;
|
|
90
102
|
}
|
package/src/context.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve the request timezone once, then read it from the ALS context.
|
|
3
|
-
*
|
|
4
|
-
* explicitly; this is where call sites get the value from, not a hidden default.
|
|
3
|
+
* Explicit preference → chosen → browser guess → config default. Every formatter still takes the
|
|
4
|
+
* zone explicitly; this is where call sites get the value from, not a hidden default.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { tryUseContext } from '@ultimat3/core';
|
|
8
|
+
import { canonicalTimeZone } from './zone-canonical';
|
|
9
|
+
import { type TimeZone, UTC } from './zones';
|
|
9
10
|
|
|
10
11
|
/** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
|
|
11
12
|
export const TIMEZONE_HEADER = 'x-timezone';
|
|
12
13
|
|
|
13
|
-
const CTX_TIMEZONE = 'timeZone';
|
|
14
|
-
|
|
15
14
|
export interface TimeZoneSources {
|
|
16
15
|
/** `user.timeZone` — an explicit preference beats a browser guess. */
|
|
17
16
|
user?: string | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
17
|
+
/** The cookie a zone picker writes — chosen, so it beats what the browser was installed as. */
|
|
18
|
+
cookie?: string | null;
|
|
20
19
|
/** `?tz=Europe/Berlin`, for share links and email previews. */
|
|
21
20
|
query?: string | null;
|
|
21
|
+
/** `x-timezone` request header. */
|
|
22
|
+
header?: string | null;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export type TimeZoneSourceName = keyof TimeZoneSources;
|
|
@@ -34,7 +35,15 @@ export interface TimeConfig {
|
|
|
34
35
|
order: readonly TimeZoneSourceName[];
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Explicit before inferred, the same rule `@ultimat3/i18n`'s locale order states: `Accept-Language`
|
|
40
|
+
* and `x-timezone` are what the browser was installed as, while the user row, the cookie and the
|
|
41
|
+
* query are what a person *chose*. A zone picker that wrote a cookie the header always outranked
|
|
42
|
+
* would appear to do nothing.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_ORDER: readonly TimeZoneSourceName[] = ['user', 'cookie', 'query', 'header'];
|
|
45
|
+
|
|
46
|
+
let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
|
|
38
47
|
|
|
39
48
|
export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
|
|
40
49
|
config = { ...config, ...partial };
|
|
@@ -46,8 +55,12 @@ export function timeConfig(): TimeConfig {
|
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
/**
|
|
49
|
-
* First valid IANA name wins
|
|
50
|
-
* `x-timezone` header from an old client must not fail the request.
|
|
58
|
+
* First valid IANA name wins, **canonicalized**. An invalid value is skipped, never thrown: a
|
|
59
|
+
* stale `x-timezone` header from an old client must not fail the request.
|
|
60
|
+
*
|
|
61
|
+
* The canonical spelling is what leaves this function, because `Intl` accepts every casing:
|
|
62
|
+
* `x-timezone: eUrOpE/bErLiN` used to travel the whole request as its own distinct zone string,
|
|
63
|
+
* and every formatter cache it reached kept a permanent entry for it.
|
|
51
64
|
*/
|
|
52
65
|
export function resolveTimeZone(
|
|
53
66
|
sources: TimeZoneSources,
|
|
@@ -57,38 +70,23 @@ export function resolveTimeZone(
|
|
|
57
70
|
for (const name of order) {
|
|
58
71
|
const candidate = sources[name];
|
|
59
72
|
if (candidate === undefined || candidate === null || candidate === '') continue;
|
|
60
|
-
|
|
73
|
+
const zone = canonicalTimeZone(candidate);
|
|
74
|
+
if (zone !== undefined) return { zone, source: name };
|
|
61
75
|
}
|
|
62
76
|
return { zone: defaultZone, source: 'default' };
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/** Ambient zone for the in-flight request; the configured default outside one. */
|
|
79
|
+
/**
|
|
80
|
+
* Ambient zone for the in-flight request; the configured default outside one.
|
|
81
|
+
*
|
|
82
|
+
* The store is **`Ctx.tz`**, core's own declared field — never a second one this package writes.
|
|
83
|
+
* It used to be a `ctx['timeZone']` key nothing in the framework ever set, while the HTTP pipeline
|
|
84
|
+
* wrote `ctx.tz`: two ambient answers to one question, and the one every `@ultimat3/ui` component
|
|
85
|
+
* reads on a server render was the empty one, so every date rendered in UTC however the request
|
|
86
|
+
* arrived. `withChildContext({ tz })` and `createContext({ tz })` are therefore the only writers,
|
|
87
|
+
* which is what makes a subtree's zone a core concept rather than this package's.
|
|
88
|
+
*/
|
|
76
89
|
export function currentTimeZone(): TimeZone {
|
|
77
|
-
const
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function tryContext(): Ctx | undefined {
|
|
82
|
-
try {
|
|
83
|
-
return useContext();
|
|
84
|
-
} catch {
|
|
85
|
-
// Outside a request scope (boot, worker tick, CLI) — fall back to the configured zone.
|
|
86
|
-
return undefined;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** `Ctx` belongs to core (tier 0) and cannot import `TimeZone`; read the field structurally. */
|
|
91
|
-
function readField(ctx: Ctx, field: string): string | undefined {
|
|
92
|
-
const value = (ctx as unknown as Record<string, unknown>)[field];
|
|
93
|
-
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
90
|
+
const zone = tryUseContext()?.tz;
|
|
91
|
+
return zone === undefined || zone === '' ? config.defaultZone : zone;
|
|
94
92
|
}
|
package/src/cron-describe.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type CronExpression, parseCronOnce } from './cron-parse';
|
|
8
|
-
import { localeInvalid } from './errors';
|
|
8
|
+
import { cronNotDescribable, localeInvalid } from './errors';
|
|
9
|
+
import { cachedFormatter } from './intl-cache';
|
|
10
|
+
import { canonicalLocale } from './locale-canonical';
|
|
9
11
|
|
|
10
12
|
export interface CronPhrases {
|
|
11
13
|
everyMinute: string;
|
|
@@ -27,15 +29,24 @@ const MAX_LISTED_TIMES = 6;
|
|
|
27
29
|
/**
|
|
28
30
|
* `describeCron('0 3 * * MON-FRI', 'en', phrases)` → `at 03:00 on Monday–Friday`.
|
|
29
31
|
* Every phrase comes from the caller's `t('time.cron.*')`; only the names are `Intl`'s.
|
|
32
|
+
*
|
|
33
|
+
* A 6-field expression whose seconds field says something a 5-field one cannot is **declined**,
|
|
34
|
+
* not summarised: `CronPhrases` has no seconds vocabulary, so a ten-second step rendered as
|
|
35
|
+
* "every minute" and `30 0 3 * * *` rendered identically to `0 3 * * *`. A summary that is wrong
|
|
36
|
+
* is worse than one that says so, and the phrases are the caller's — adding a required field to
|
|
37
|
+
* `CronPhrases` breaks every existing caller to describe a schedule almost nobody writes.
|
|
30
38
|
*/
|
|
31
39
|
export function describeCron(
|
|
32
40
|
expression: string | CronExpression,
|
|
33
41
|
locale: string,
|
|
34
42
|
phrases: CronPhrases,
|
|
35
43
|
): string {
|
|
36
|
-
|
|
44
|
+
// Canonicalized once, at the entry point, and `tag` is what every line below uses — `EN-us` and
|
|
45
|
+
// `en-US` are one locale to `Intl`, and must be one key in the caches at the foot of this file.
|
|
46
|
+
const tag = assertLocale(locale);
|
|
37
47
|
const cron = parseCronOnce(expression);
|
|
38
|
-
|
|
48
|
+
if (cron.seconds.length !== 1 || cron.seconds[0] !== 0) throw cronNotDescribable(cron);
|
|
49
|
+
const list = new Intl.ListFormat(tag, { style: 'long', type: 'conjunction' });
|
|
39
50
|
const segments: string[] = [];
|
|
40
51
|
|
|
41
52
|
const minuteStep = uniformStep(cron.minutes, 60);
|
|
@@ -52,7 +63,7 @@ export function describeCron(
|
|
|
52
63
|
segments.push(hourStep === 1 ? phrases.everyHour : fill(phrases.everyNHours, { n: hourStep }));
|
|
53
64
|
} else {
|
|
54
65
|
explicitTime = true;
|
|
55
|
-
segments.push(fill(phrases.at, { time: clockTimes(cron, phrases,
|
|
66
|
+
segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, tag, list) }));
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
if (cron.dayOfMonthRestricted) {
|
|
@@ -60,11 +71,11 @@ export function describeCron(
|
|
|
60
71
|
segments.push(fill(phrases.onDaysOfMonth, { days }));
|
|
61
72
|
}
|
|
62
73
|
if (cron.dayOfWeekRestricted) {
|
|
63
|
-
const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day,
|
|
74
|
+
const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, tag)));
|
|
64
75
|
segments.push(fill(phrases.onWeekdays, { days }));
|
|
65
76
|
}
|
|
66
77
|
if (cron.months.length < 12) {
|
|
67
|
-
const months = list.format(cron.months.map((month) => monthName(month,
|
|
78
|
+
const months = list.format(cron.months.map((month) => monthName(month, tag)));
|
|
68
79
|
segments.push(fill(phrases.inMonths, { months }));
|
|
69
80
|
}
|
|
70
81
|
if (explicitTime && segments.length === 1) segments.push(phrases.everyDay);
|
|
@@ -89,13 +100,14 @@ function clockTimes(
|
|
|
89
100
|
return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
/**
|
|
104
|
+
* `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point — and
|
|
105
|
+
* hand back the canonical spelling, so validating and keying are the same single step.
|
|
106
|
+
*/
|
|
107
|
+
function assertLocale(locale: string): string {
|
|
108
|
+
const tag = canonicalLocale(locale);
|
|
109
|
+
if (tag === undefined) throw localeInvalid(locale);
|
|
110
|
+
return tag;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
/** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
|
|
@@ -118,38 +130,33 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
|
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
/**
|
|
121
|
-
*
|
|
122
|
-
* header
|
|
123
|
-
* every unknown `-u-` extension value, so
|
|
124
|
-
*
|
|
133
|
+
* Canonically keyed **and** hard-capped, because `locale` can arrive from an Accept-Language
|
|
134
|
+
* header. `canonicalLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
|
|
135
|
+
* still returns a distinct string for every unknown `-u-` extension value, so the key alone does
|
|
136
|
+
* not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
|
|
137
|
+
* cap and its FIFO live in `intl-cache.ts`, because `zones.ts` and `format.ts` needed the same
|
|
138
|
+
* rule and a hazard documented in one file is a hazard the other two repeat.
|
|
139
|
+
*
|
|
140
|
+
* Both caches are fed the canonical `tag` by `describeCron` alone, never a caller string.
|
|
125
141
|
*/
|
|
126
|
-
const MAX_CACHED_LOCALES = 32;
|
|
127
142
|
const monthFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
128
143
|
const weekdayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
129
144
|
|
|
130
|
-
function formatterFor(
|
|
131
|
-
cache: Map<string, Intl.DateTimeFormat>,
|
|
132
|
-
locale: string,
|
|
133
|
-
options: Intl.DateTimeFormatOptions,
|
|
134
|
-
): Intl.DateTimeFormat {
|
|
135
|
-
const hit = cache.get(locale);
|
|
136
|
-
if (hit !== undefined) return hit;
|
|
137
|
-
const formatter = new Intl.DateTimeFormat(locale, options);
|
|
138
|
-
if (cache.size >= MAX_CACHED_LOCALES) {
|
|
139
|
-
const oldest = cache.keys().next().value;
|
|
140
|
-
if (oldest !== undefined) cache.delete(oldest);
|
|
141
|
-
}
|
|
142
|
-
cache.set(locale, formatter);
|
|
143
|
-
return formatter;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
145
|
function monthName(month: number, locale: string): string {
|
|
147
|
-
const formatter =
|
|
146
|
+
const formatter = cachedFormatter(
|
|
147
|
+
monthFormatters,
|
|
148
|
+
locale,
|
|
149
|
+
() => new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' }),
|
|
150
|
+
);
|
|
148
151
|
return formatter.format(new Date(Date.UTC(2026, month - 1, 1)));
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
function weekdayName(isoDay: number, locale: string): string {
|
|
152
|
-
const formatter =
|
|
155
|
+
const formatter = cachedFormatter(
|
|
156
|
+
weekdayFormatters,
|
|
157
|
+
locale,
|
|
158
|
+
() => new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }),
|
|
159
|
+
);
|
|
153
160
|
// 2026-06-01 is a Monday, so ISO day N is that date + (N - 1).
|
|
154
161
|
return formatter.format(new Date(Date.UTC(2026, 5, isoDay)));
|
|
155
162
|
}
|
package/src/cron-parse.ts
CHANGED
|
@@ -141,9 +141,14 @@ function parseField(
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
if (from > to) {
|
|
144
|
-
// Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom, and the stride CONTINUES across
|
|
145
|
+
// the wrap: `23-3/2` is 23, 01, 03 — every second hour starting at 23. Restarting at `min`
|
|
146
|
+
// answered 23, 00, 02, an hour off for every occurrence past midnight.
|
|
147
|
+
const span = max - min + 1;
|
|
148
|
+
const length = to - from + span;
|
|
149
|
+
for (let offset = 0; offset <= length; offset += step) {
|
|
150
|
+
values.add(min + ((from - min + offset) % span));
|
|
151
|
+
}
|
|
147
152
|
} else {
|
|
148
153
|
for (let value = from; value <= to; value += step) values.add(value);
|
|
149
154
|
}
|
package/src/errors.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* DST ambiguity is a real state of the world, so it gets a code instead of a guess.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { registerErrorCodes, renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
7
7
|
|
|
8
8
|
export const TIME_ERROR_CODES = [
|
|
9
9
|
'X_TIMEZONE_INVALID',
|
|
@@ -14,6 +14,7 @@ export const TIME_ERROR_CODES = [
|
|
|
14
14
|
'X_INSTANT_INVALID',
|
|
15
15
|
'X_SCHEDULE_INVALID',
|
|
16
16
|
'X_LOCALE_INVALID',
|
|
17
|
+
'X_CRON_NOT_DESCRIBABLE',
|
|
17
18
|
] as const;
|
|
18
19
|
|
|
19
20
|
export type TimeErrorCode = (typeof TIME_ERROR_CODES)[number];
|
|
@@ -27,6 +28,7 @@ export const TIME_ERROR_TITLES: Readonly<Record<TimeErrorCode, string>> = {
|
|
|
27
28
|
X_INSTANT_INVALID: 'not a parseable instant',
|
|
28
29
|
X_SCHEDULE_INVALID: 'a wall-clock field is out of range',
|
|
29
30
|
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
31
|
+
X_CRON_NOT_DESCRIBABLE: 'a valid cron expression describeCron has no vocabulary for',
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -54,7 +56,9 @@ export class TimeError extends UltimateError {
|
|
|
54
56
|
export function scheduleInvalid(field: string, value: unknown, range: string): TimeError {
|
|
55
57
|
return new TimeError({
|
|
56
58
|
code: 'X_SCHEDULE_INVALID',
|
|
57
|
-
|
|
59
|
+
// `value` is whatever a caller put in a `LocalSlot` — this factory is exported, so it is a form
|
|
60
|
+
// field or a config value as often as it is the `number` the in-package caller passes.
|
|
61
|
+
cause: `${field} must be ${range}, got ${renderCauseValue(value)}`,
|
|
58
62
|
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`,
|
|
59
63
|
});
|
|
60
64
|
}
|
|
@@ -75,6 +79,22 @@ export function cronInvalid(expression: string, reason: string): TimeError {
|
|
|
75
79
|
});
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The expression parses and schedules correctly — `describeCron` just cannot put it into words.
|
|
84
|
+
* Separate from `X_CRON_INVALID`, which is a typo: this one is a valid schedule, and telling the
|
|
85
|
+
* caller to fix their cron would be telling them to break a working task.
|
|
86
|
+
*/
|
|
87
|
+
export function cronNotDescribable(cron: {
|
|
88
|
+
source: string;
|
|
89
|
+
seconds: readonly number[];
|
|
90
|
+
}): TimeError {
|
|
91
|
+
return new TimeError({
|
|
92
|
+
code: 'X_CRON_NOT_DESCRIBABLE',
|
|
93
|
+
cause: `cron "${cron.source}" fires on second ${cron.seconds.join(',')}, and CronPhrases has no seconds vocabulary`,
|
|
94
|
+
fix: `render the real runs instead of a summary — nextCronOccurrences('${cron.source}', zone, from, 3) — or, if second-level precision is not wanted, describe the 5-field schedule describeCron('${cron.source.split(/\s+/).slice(1).join(' ')}', locale, phrases)`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
export function durationInvalid(input: string): TimeError {
|
|
79
99
|
return new TimeError({
|
|
80
100
|
code: 'X_DURATION_INVALID',
|
package/src/format.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { differenceMs, type Instant } from './instant';
|
|
8
|
+
import { cachedFormatter } from './intl-cache';
|
|
9
|
+
import { canonicalLocale } from './locale-canonical';
|
|
8
10
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
9
11
|
|
|
10
12
|
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
|
@@ -165,11 +167,17 @@ export function ordinal(value: number, locale = 'en'): string {
|
|
|
165
167
|
|
|
166
168
|
const cache = new Map<string, Intl.DateTimeFormat>();
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Bounded, and keyed on a zone `assertTimeZone` and a locale `canonicalLocale` have both already
|
|
172
|
+
* canonicalized — `Accept-Language` sends `EN-us` and `en-US` for one locale, and each spelling
|
|
173
|
+
* used to mint its own permanent entry. The bound stays: an unknown `-u-` extension value survives
|
|
174
|
+
* canonicalization as a distinct string, so only the cap keeps this key space finite.
|
|
175
|
+
*
|
|
176
|
+
* A tag `Intl` cannot parse falls through unchanged, so the `Intl.DateTimeFormat` constructor
|
|
177
|
+
* still raises it — this seam decides a cache key, never whether a locale is acceptable.
|
|
178
|
+
*/
|
|
168
179
|
function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const formatter = new Intl.DateTimeFormat(locale, options);
|
|
173
|
-
cache.set(key, formatter);
|
|
174
|
-
return formatter;
|
|
180
|
+
const tag = canonicalLocale(locale) ?? locale;
|
|
181
|
+
const key = `${tag}|${JSON.stringify(options)}`;
|
|
182
|
+
return cachedFormatter(cache, key, () => new Intl.DateTimeFormat(tag, options));
|
|
175
183
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,6 @@ export {
|
|
|
14
14
|
WEEKEND_SUN_ONLY,
|
|
15
15
|
} from './business';
|
|
16
16
|
export {
|
|
17
|
-
attachTimeZone,
|
|
18
17
|
configureTime,
|
|
19
18
|
currentTimeZone,
|
|
20
19
|
resolveTimeZone,
|
|
@@ -24,7 +23,6 @@ export {
|
|
|
24
23
|
type TimeZoneSourceName,
|
|
25
24
|
type TimeZoneSources,
|
|
26
25
|
timeConfig,
|
|
27
|
-
timeZoneOf,
|
|
28
26
|
} from './context';
|
|
29
27
|
export {
|
|
30
28
|
type CronExpression,
|
|
@@ -54,6 +52,7 @@ export {
|
|
|
54
52
|
} from './duration';
|
|
55
53
|
export {
|
|
56
54
|
cronInvalid,
|
|
55
|
+
cronNotDescribable,
|
|
57
56
|
dstAmbiguous,
|
|
58
57
|
dstNonexistent,
|
|
59
58
|
durationInvalid,
|
|
@@ -83,7 +82,7 @@ export {
|
|
|
83
82
|
addMs,
|
|
84
83
|
compareInstants,
|
|
85
84
|
differenceMs,
|
|
86
|
-
|
|
85
|
+
epoch,
|
|
87
86
|
fromEpochMs,
|
|
88
87
|
fromEpochSeconds,
|
|
89
88
|
fromIso,
|
|
@@ -105,6 +104,11 @@ export {
|
|
|
105
104
|
nextWeeklySlot,
|
|
106
105
|
type WeeklySlot,
|
|
107
106
|
} from './schedule';
|
|
107
|
+
/**
|
|
108
|
+
* `canonicalTimeZone` is public because a zone arriving from a request header has to become one
|
|
109
|
+
* key before it reaches anything that caches on it — `@ultimat3/http` reads `x-timezone`.
|
|
110
|
+
*/
|
|
111
|
+
export { canonicalTimeZone } from './zone-canonical';
|
|
108
112
|
export {
|
|
109
113
|
addDaysInZone,
|
|
110
114
|
daysBetween,
|
package/src/instant.ts
CHANGED
|
@@ -12,10 +12,16 @@ declare const instantBrand: unique symbol;
|
|
|
12
12
|
/** A `Date` that has been proven valid and is documented as UTC. */
|
|
13
13
|
export type Instant = Date & { readonly [instantBrand]: 'utc' };
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a `Date` from an untrusted source (a DB driver, a parsed payload).
|
|
17
|
+
*
|
|
18
|
+
* A **copy**, never the caller's own object: `value as Instant` handed back a `Date` the caller
|
|
19
|
+
* still holds and can `setTime()` after the brand is applied, so a value this function certified
|
|
20
|
+
* as valid could stop being the value it certified.
|
|
21
|
+
*/
|
|
16
22
|
export function instant(value: Date): Instant {
|
|
17
23
|
if (Number.isNaN(value.getTime())) throw instantInvalid(String(value));
|
|
18
|
-
return value as Instant;
|
|
24
|
+
return new Date(value.getTime()) as Instant;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
|
|
@@ -86,7 +92,18 @@ export function isInstant(value: unknown): value is Instant {
|
|
|
86
92
|
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A fresh instant at the Unix epoch, per call.
|
|
97
|
+
*
|
|
98
|
+
* Replaces the `EPOCH` constant, which was one shared mutable `Date` exported from a tier-1
|
|
99
|
+
* package: a single `EPOCH.setUTCFullYear(...)` anywhere in the process corrupted it for every
|
|
100
|
+
* other consumer, permanently and silently. A `Date` cannot be frozen — `Object.freeze` does not
|
|
101
|
+
* close `setTime`, because the value lives in an internal slot — so the only safe shape is a
|
|
102
|
+
* function. **Breaking: `EPOCH` is removed**; it had no callers in this repo.
|
|
103
|
+
*/
|
|
104
|
+
export function epoch(): Instant {
|
|
105
|
+
return new Date(0) as Instant;
|
|
106
|
+
}
|
|
90
107
|
|
|
91
108
|
/** Tolerates a `Clock` whose `now()` returns either a `Date` or epoch milliseconds. */
|
|
92
109
|
function epochMsOf(value: Date | number): number {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One bounded cache for every `Intl` formatter this package builds.
|
|
3
|
+
* A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on that
|
|
4
|
+
* string is memory the client chooses: 4,096 case-variants of one zone name retained 31 MB,
|
|
5
|
+
* ~7.7 KB per `Intl.DateTimeFormat`, and 600 zones times 2^12 casings has no ceiling at all.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
|
|
10
|
+
* and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
|
|
11
|
+
* `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_CACHED_FORMATTERS = 512;
|
|
14
|
+
|
|
15
|
+
/** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
|
|
16
|
+
export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
|
|
17
|
+
const hit = cache.get(key);
|
|
18
|
+
if (hit !== undefined) return hit;
|
|
19
|
+
const formatter = build();
|
|
20
|
+
if (cache.size >= MAX_CACHED_FORMATTERS) {
|
|
21
|
+
const oldest = cache.keys().next().value;
|
|
22
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
cache.set(key, formatter);
|
|
25
|
+
return formatter;
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One locale, one key. `Intl` accepts `EN-us`, `en-US` and `en-latn-us` as the same locale, so a
|
|
3
|
+
* cache keyed on the caller's spelling holds three formatters where one would do — and the caller
|
|
4
|
+
* is `Accept-Language`. The twin of `zone-canonical.ts`, for the other header-supplied string.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
9
|
+
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
10
|
+
* falls back for it, and refusing here would be stricter than the formatters this feeds.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
|
|
13
|
+
* unbounded cache the whole `intl-cache.ts` bound exists to prevent.
|
|
14
|
+
*/
|
|
15
|
+
export function canonicalLocale(locale: string): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
// `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
|
|
18
|
+
// `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
|
|
19
|
+
return Intl.getCanonicalLocales(locale)[0];
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/schedule.ts
CHANGED
|
@@ -25,9 +25,9 @@ export interface LocalSlot {
|
|
|
25
25
|
* gap, `{ gap: 'next' }` picks the first existing local time instead of skipping the day.
|
|
26
26
|
*/
|
|
27
27
|
/** Wall-clock fields are never wrapped or clamped — a shifted schedule beats no schedule. */
|
|
28
|
-
function assertWallField(field: string, value: number, max: number): void {
|
|
29
|
-
if (!Number.isInteger(value) || value <
|
|
30
|
-
throw scheduleInvalid(field, value, `an integer
|
|
28
|
+
function assertWallField(field: string, value: number, max: number, min = 0): void {
|
|
29
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
30
|
+
throw scheduleInvalid(field, value, `an integer ${String(min)}-${String(max)}`);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -80,11 +80,16 @@ export interface WeeklySlot extends LocalSlot {
|
|
|
80
80
|
|
|
81
81
|
/** Next `weekday` at the local time, strictly after `after`. */
|
|
82
82
|
export function nextWeeklySlot(slot: WeeklySlot, after: Instant): Instant {
|
|
83
|
+
// Checked BEFORE the search, not after it. Falling out of the loop reported X_TIMEZONE_INVALID
|
|
84
|
+
// naming a zone that is perfectly valid, with a fix line about IANA identifiers that fixes
|
|
85
|
+
// nothing — the field the caller got wrong is `weekday`, and an error has to say so.
|
|
86
|
+
assertWallField('slot.weekday', slot.weekday, 7, 1);
|
|
83
87
|
let cursor = after;
|
|
84
88
|
for (let index = 0; index < 8; index += 1) {
|
|
85
89
|
const candidate = nextLocalSlot(slot, cursor);
|
|
86
90
|
if (toZoned(candidate, slot.zone).weekday === slot.weekday) return candidate;
|
|
87
91
|
cursor = candidate;
|
|
88
92
|
}
|
|
93
|
+
// Seven local days always contain every ISO weekday; reaching here means the zone data is broken.
|
|
89
94
|
throw timezoneInvalid(slot.zone);
|
|
90
95
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One IANA zone, one key. `Intl` accepts every casing of a zone name, so `Europe/Berlin` and
|
|
3
|
+
* `eUrOpE/bErLiN` reach a formatter cache — and every downstream comparison — as two zones.
|
|
4
|
+
* A 13-letter name has 2^12 casings and a request header can name any of them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { cachedFormatter } from './intl-cache';
|
|
8
|
+
|
|
9
|
+
/** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
|
|
10
|
+
const NUMERIC_OFFSET = /^[+-]/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lowercase → canonical, for every zone the runtime lists. Built once, ~445 entries, and it is
|
|
14
|
+
* what makes the common case — a casing of a real zone — cost a string lookup instead of an
|
|
15
|
+
* `Intl.DateTimeFormat` construction the caller can mint at will.
|
|
16
|
+
*/
|
|
17
|
+
let listed: Map<string, string> | undefined;
|
|
18
|
+
|
|
19
|
+
function listedZones(): Map<string, string> {
|
|
20
|
+
if (listed !== undefined) return listed;
|
|
21
|
+
const table = new Map<string, string>();
|
|
22
|
+
for (const zone of Intl.supportedValuesOf('timeZone')) table.set(zone.toLowerCase(), zone);
|
|
23
|
+
listed = table;
|
|
24
|
+
return table;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) and the runtime's extras (`EST`, `GMT`) are
|
|
29
|
+
* not in the listed set, so they take the `Intl` probe once — bounded for the same reason every
|
|
30
|
+
* other cache here is.
|
|
31
|
+
*/
|
|
32
|
+
const probed = new Map<string, string | ''>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The canonical spelling of an IANA zone, or `undefined` for anything that is not one.
|
|
36
|
+
* `'CET'` and `'+01:00'` are not zones: an abbreviation is ambiguous and an offset has no rules.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalTimeZone(zone: string): string | undefined {
|
|
39
|
+
if (zone === '' || NUMERIC_OFFSET.test(zone)) return undefined;
|
|
40
|
+
const known = listedZones().get(zone.toLowerCase());
|
|
41
|
+
if (known !== undefined) return known;
|
|
42
|
+
// `''` is the cached "not a zone" answer — a `Map` miss and a cached refusal must not look the
|
|
43
|
+
// same, or every invalid header re-probes `Intl` forever.
|
|
44
|
+
const resolved = cachedFormatter(probed, zone, () => resolve(zone));
|
|
45
|
+
return resolved === '' ? undefined : resolved;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolve(zone: string): string | '' {
|
|
49
|
+
try {
|
|
50
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone;
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/zones.ts
CHANGED
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { timezoneInvalid } from './errors';
|
|
8
8
|
import type { Instant } from './instant';
|
|
9
|
+
import { cachedFormatter } from './intl-cache';
|
|
10
|
+
import { canonicalTimeZone } from './zone-canonical';
|
|
9
11
|
|
|
10
12
|
/** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
|
|
11
13
|
export type TimeZone = string;
|
|
12
14
|
|
|
13
15
|
export const UTC: TimeZone = 'UTC';
|
|
14
16
|
|
|
15
|
-
/** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
|
|
16
|
-
const NUMERIC_OFFSET = /^[+-]/;
|
|
17
|
-
|
|
18
17
|
/**
|
|
19
18
|
* The package's only builder of a UTC epoch from calendar fields, because `Date.UTC` remaps
|
|
20
19
|
* years 0–99 onto 1900–1999 — silently, so a first-century wall clock resolves 1900 years off
|
|
@@ -36,18 +35,18 @@ export function utcEpoch(
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
export function isValidTimeZone(zone: string): boolean {
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
new Intl.DateTimeFormat('en-US', { timeZone: zone });
|
|
42
|
-
return true;
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
38
|
+
return canonicalTimeZone(zone) !== undefined;
|
|
46
39
|
}
|
|
47
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The **canonical** spelling, not the caller's. `Intl` answers for every casing of a zone name, so
|
|
43
|
+
* returning the input let one zone travel the process as many strings — each one its own key in
|
|
44
|
+
* every formatter cache downstream, and unbounded when the string came from a request header.
|
|
45
|
+
*/
|
|
48
46
|
export function assertTimeZone(zone: string): TimeZone {
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const canonical = canonicalTimeZone(zone);
|
|
48
|
+
if (canonical === undefined) throw timezoneInvalid(zone);
|
|
49
|
+
return canonical;
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
/** Wall-clock fields of an instant in a zone, seconds precision. */
|
|
@@ -127,13 +126,20 @@ export function zoneAbbrev(
|
|
|
127
126
|
return label ?? offsetLabel(offsetAt(zone, at));
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* True when the zone observes a different offset at some point in the surrounding year.
|
|
131
|
+
*
|
|
132
|
+
* Twelve probes on the FIRST of twelve consecutive months. `setUTCMonth(+n)` rolls over at month
|
|
133
|
+
* end — from 31 January the twelve probes land in January, March, March, May, May, July, July,
|
|
134
|
+
* August, October, October, December, December, so February, April, June, September and November
|
|
135
|
+
* are never asked, and a zone whose only transition falls in one of them reads as DST-free.
|
|
136
|
+
*/
|
|
131
137
|
export function observesDst(zone: TimeZone, at: Instant): boolean {
|
|
138
|
+
const start = zonePartsAt(zone, at);
|
|
132
139
|
const offsets = new Set<number>();
|
|
133
140
|
for (let month = 0; month < 12; month += 1) {
|
|
134
|
-
const probe = new Date(
|
|
135
|
-
|
|
136
|
-
offsets.add(offsetAt(zone, probe as Instant));
|
|
141
|
+
const probe = new Date(utcEpoch(start.year, start.month + month, 1, 12)) as Instant;
|
|
142
|
+
offsets.add(offsetAt(zone, probe));
|
|
137
143
|
}
|
|
138
144
|
return offsets.size > 1;
|
|
139
145
|
}
|
|
@@ -141,25 +147,24 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
|
|
|
141
147
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
142
148
|
|
|
143
149
|
function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return formatter;
|
|
150
|
+
// Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
|
|
151
|
+
const canonical = canonicalTimeZone(zone);
|
|
152
|
+
if (canonical === undefined) throw timezoneInvalid(zone);
|
|
153
|
+
return cachedFormatter(
|
|
154
|
+
formatters,
|
|
155
|
+
canonical,
|
|
156
|
+
() =>
|
|
157
|
+
new Intl.DateTimeFormat('en-US', {
|
|
158
|
+
timeZone: canonical,
|
|
159
|
+
hourCycle: 'h23',
|
|
160
|
+
year: 'numeric',
|
|
161
|
+
month: '2-digit',
|
|
162
|
+
day: '2-digit',
|
|
163
|
+
hour: '2-digit',
|
|
164
|
+
minute: '2-digit',
|
|
165
|
+
second: '2-digit',
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
function pad2(value: number): string {
|