@ultimat3/time 1.2.0 → 3.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 ADDED
@@ -0,0 +1,81 @@
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
+ | `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
14
+ | `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
15
+ | `duration.ts` | `'2h30m'` ⇄ ms |
16
+ | `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
17
+ | `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
18
+ | `cron-occurrence.ts` | next occurrence, wall-clock driven |
19
+ | `cron-describe.ts` | `describeCron` — `Intl` names, phrases injected. `CronPhrases` is required. |
20
+ | `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
21
+ | `business.ts` | weekends as config, holidays as local dates |
22
+ | `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
23
+ | `plain-date.ts` | `PlainDate` — a calendar date with no time and no zone, and the two conversions to an instant |
24
+
25
+ ## Rules
26
+
27
+ - Never format without an explicit `timeZone`. No ambient default, no `toLocaleString()`.
28
+ - **The ambient zone IS `Ctx.tz`**, core's own declared field. This package publishes no writer and
29
+ no field of its own: `createContext({ tz })` and `withChildContext({ tz })` are the way in,
30
+ `currentTimeZone()` the way out. It kept `attachTimeZone`/`timeZoneOf` over `ctx['timeZone']`
31
+ until 1.3.0 — a second ambient store, with **zero** writers, while `@ultimat3/http` wrote `tz` —
32
+ so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render
33
+ formatted in UTC regardless of the zone the caller sent. Two ambient defaults is the worst
34
+ possible version of the rule above. Never reintroduce either half.
35
+ - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
36
+ a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
37
+ for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
38
+ `MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this
39
+ package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not
40
+ import sideways, so the mechanism moved down a tier rather than being copied. An unbounded
41
+ `Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
42
+ of a 13-letter zone is 2^12. **Both halves, always** — a canonical key does not bound anything
43
+ (an unknown `-u-` extension value survives canonicalization as a distinct string) and the cap
44
+ alone lets one locale evict itself under three spellings.
45
+ - **Never hand back the caller's own `Date`, and never export a shared one.** `Instant` is a
46
+ branded `Date` and a `Date` cannot be frozen — `Object.freeze` does not close `setTime`, the
47
+ value is in an internal slot. So `instant()` copies and `epoch()` is a function, not a constant.
48
+ - **`businessDaysBetween` is `[from, to)` on local calendar dates** — the interval `daysBetween`
49
+ measures, so the two can never disagree. Comparing instants made the answer depend on the
50
+ endpoints' time of day. A new day-counting function states its interval in its header.
51
+ - **`describeCron` declines what it cannot say.** `CronPhrases` is the caller's vocabulary and has
52
+ no seconds phrase, so a 6-field expression with a non-trivial seconds field is
53
+ `X_CRON_NOT_DESCRIBABLE`. Adding a required field to `CronPhrases` would break every caller
54
+ (`packages/cli/src/cmd-tasks.ts` builds one) to describe a schedule almost nobody writes.
55
+ - **A `PlainDate` is a branded STRING, and the golden rule does not apply to it** — decided
56
+ 2026-08 for `@ultimat3/entity`'s `date()` column. "Never format a date without a zone" is about
57
+ INSTANTS; a calendar date names no instant, so it needs no zone, and giving it one is the bug:
58
+ `effective_on` stored as a `timestamptz` is a different date on either side of midnight for half
59
+ the planet. Not a `Date` (that is an instant, and binding one to a Postgres `date` parameter
60
+ fails outright — `time zone "gmt-0500" not recognized`, measured on 17.10) and not
61
+ `{ year, month, day }` (an object sorts by nothing and JSON-stringifies as three fields). The ISO
62
+ string sorts lexicographically exactly as it sorts chronologically, which is why every cursor,
63
+ `orderBy` and `compare` in the framework handles it with no special case at all.
64
+ - **`plainDateIn` takes a zone and `plainDateUtc` does not, and neither is a default for the
65
+ other.** An instant has a calendar date only in a zone; a `Date` a driver returns for a `date`
66
+ column is midnight UTC and reading its LOCAL fields loses a day west of Greenwich. `bun test`
67
+ pins the process to UTC, so that bug is invisible to every in-process test — `plain-date.test.ts`
68
+ spawns a subprocess with `TZ=America/Los_Angeles` for exactly one assertion, and that is the only
69
+ reason it can fail.
70
+ - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
71
+ - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
72
+ - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
73
+ - `m` is minutes, `ms` is milliseconds. A bare number is not a duration.
74
+ - Tests must cover a spring-forward gap, a fall-back overlap and a non-hour offset zone.
75
+
76
+ ## Commands
77
+
78
+ ```
79
+ bun test packages/time
80
+ bun run --filter @ultimat3/time typecheck
81
+ ```
package/README.md CHANGED
@@ -16,6 +16,22 @@ 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 stays either way: an unknown `-u-` extension value survives
27
+ canonicalization as a distinct string, so the key bounds nothing on its own. Both halves —
28
+ `canonicalLocale` and `cachedFormatter` — are `@ultimat3/core`'s as of 2.0.0, so `@ultimat3/money`
29
+ reads the same bound rather than a copy of it.
30
+
31
+ Every value this package hands back is its own object: `instant(date)` copies rather than branding
32
+ the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
33
+ mutable `Date` that a single `setUTCFullYear` corrupted for the whole process.
34
+
19
35
  ## Use
20
36
 
21
37
  ```ts
@@ -64,6 +80,34 @@ one of these takes the zone explicitly, and none of them has a default.
64
80
  `daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
65
81
  and 24 real hours inside a 25-hour fall-back day is `0`.
66
82
 
83
+ ## A calendar date is not an instant
84
+
85
+ `PlainDate` is `2026-03-14`: a year, a month and a day, with **no time and therefore no zone**,
86
+ `As of 2026-08`. The golden rule at the top of this page is about instants — a `PlainDate` needs no
87
+ zone because it names no moment, and that is the honest modelling of the values that have one.
88
+ `effective_on` is the date a rate applies; a birthday is a date; an invoice period is two of them.
89
+ Stored as a `timestamptz`, every one of those is a different date on either side of midnight for
90
+ half the planet.
91
+
92
+ | Function | Answers |
93
+ |---|---|
94
+ | `plainDate(value)` / `isPlainDate(value)` | the date, or a refusal — `2026-02-30` is not one, and a regex cannot say so |
95
+ | `plainDateOf({ year, month, day })` | the same, from fields; an impossible day throws instead of rolling into the next month |
96
+ | `plainDateParts(date)` | back to fields |
97
+ | `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 |
98
+ | `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 |
99
+ | `plainDateToUtcInstant(date)` | midnight UTC of the date. The inverse of `plainDateUtc`, and never of `plainDateIn` |
100
+ | `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 |
101
+ | `comparePlainDates(a, b)` | `-1` / `0` / `1` |
102
+
103
+ It is a branded **string**, and both halves are load-bearing. Not a `Date`: a `Date` is an instant,
104
+ so `2026-03-14T00:00:00Z` is the 13th anywhere west of Greenwich, and binding one to a Postgres
105
+ `date` parameter fails outright (`time zone "gmt-0500" not recognized`, measured on 17.10). A
106
+ string: the ISO form sorts lexicographically exactly as it sorts chronologically, round-trips
107
+ through `JSON.stringify` as itself, and is the literal Postgres accepts and returns.
108
+
109
+ `@ultimat3/entity`'s `date()` column is the one that stores it.
110
+
67
111
  ## Cron
68
112
 
69
113
  `parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
@@ -73,13 +117,21 @@ change, and a job scheduled inside the gap runs at the first existing local time
73
117
  being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
74
118
  weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
75
119
  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.
120
+ long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
121
+ 6-field expression whose seconds field says something a 5-field one cannot is **declined** with
122
+ `X_CRON_NOT_DESCRIBABLE` rather than summarised: `CronPhrases` has no seconds vocabulary, so a
123
+ ten-second step used to render as "every minute".
77
124
 
78
125
  ## Business days
79
126
 
80
127
  The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
81
128
  `WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
82
129
 
130
+ `businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
131
+ days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
132
+ neither endpoint's wall-clock time is part of the question. A reversed range is the same count
133
+ negated; an empty one is `0` in either direction, never `-0`.
134
+
83
135
  ## Errors
84
136
 
85
137
  | Code | When |
@@ -91,6 +143,8 @@ The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the
91
143
  | `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
92
144
  | `X_INSTANT_INVALID` | unparseable timestamp |
93
145
  | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` |
146
+ | `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
147
+ | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` |
94
148
 
95
149
  ## Why it exists
96
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "1.2.0",
3
+ "version": "3.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": "1.2.0"
34
+ "@ultimat3/core": "3.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)`, counted on local calendar days. Order-independent:
72
- * a reversed range returns a negative count.
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
- while (isoDateInZone(cursor, calendar.zone) !== isoDateInZone(end, calendar.zone)) {
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
- return sign * count;
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
- * User recordheader → config default. Every formatter still takes the zone
4
- * explicitly; this is where call sites get the value from, not a hidden default.
3
+ * Explicit preferencechosenbrowser 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 { type Ctx, useContext } from '@ultimat3/core';
8
- import { isValidTimeZone, type TimeZone, UTC } from './zones';
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
- /** `x-timezone` request header. */
19
- header?: string | null;
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
- let config: TimeConfig = { defaultZone: UTC, order: ['user', 'header', 'query'] };
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. An invalid value is skipped, never thrown: a stale
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
- if (isValidTimeZone(candidate)) return { zone: candidate, source: name };
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
- /** Called once per request by the HTTP layer. */
66
- export function attachTimeZone(ctx: Ctx, zone: TimeZone): TimeZone {
67
- (ctx as unknown as Record<string, unknown>)[CTX_TIMEZONE] = zone;
68
- return zone;
69
- }
70
-
71
- export function timeZoneOf(ctx: Ctx): TimeZone {
72
- return readField(ctx, CTX_TIMEZONE) ?? config.defaultZone;
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 ctx = tryContext();
78
- return (ctx === undefined ? undefined : readField(ctx, CTX_TIMEZONE)) ?? config.defaultZone;
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
  }
@@ -4,8 +4,9 @@
4
4
  * ship English to every locale that forgot the argument — so injection is mandatory, not opt-in.
5
5
  */
6
6
 
7
+ import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
8
  import { type CronExpression, parseCronOnce } from './cron-parse';
8
- import { localeInvalid } from './errors';
9
+ import { cronNotDescribable, localeInvalid } from './errors';
9
10
 
10
11
  export interface CronPhrases {
11
12
  everyMinute: string;
@@ -27,15 +28,24 @@ const MAX_LISTED_TIMES = 6;
27
28
  /**
28
29
  * `describeCron('0 3 * * MON-FRI', 'en', phrases)` → `at 03:00 on Monday–Friday`.
29
30
  * Every phrase comes from the caller's `t('time.cron.*')`; only the names are `Intl`'s.
31
+ *
32
+ * A 6-field expression whose seconds field says something a 5-field one cannot is **declined**,
33
+ * not summarised: `CronPhrases` has no seconds vocabulary, so a ten-second step rendered as
34
+ * "every minute" and `30 0 3 * * *` rendered identically to `0 3 * * *`. A summary that is wrong
35
+ * is worse than one that says so, and the phrases are the caller's — adding a required field to
36
+ * `CronPhrases` breaks every existing caller to describe a schedule almost nobody writes.
30
37
  */
31
38
  export function describeCron(
32
39
  expression: string | CronExpression,
33
40
  locale: string,
34
41
  phrases: CronPhrases,
35
42
  ): string {
36
- assertLocale(locale);
43
+ // Canonicalized once, at the entry point, and `tag` is what every line below uses — `EN-us` and
44
+ // `en-US` are one locale to `Intl`, and must be one key in the caches at the foot of this file.
45
+ const tag = assertLocale(locale);
37
46
  const cron = parseCronOnce(expression);
38
- const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' });
47
+ if (cron.seconds.length !== 1 || cron.seconds[0] !== 0) throw cronNotDescribable(cron);
48
+ const list = new Intl.ListFormat(tag, { style: 'long', type: 'conjunction' });
39
49
  const segments: string[] = [];
40
50
 
41
51
  const minuteStep = uniformStep(cron.minutes, 60);
@@ -52,7 +62,7 @@ export function describeCron(
52
62
  segments.push(hourStep === 1 ? phrases.everyHour : fill(phrases.everyNHours, { n: hourStep }));
53
63
  } else {
54
64
  explicitTime = true;
55
- segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, locale, list) }));
65
+ segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, tag, list) }));
56
66
  }
57
67
 
58
68
  if (cron.dayOfMonthRestricted) {
@@ -60,11 +70,11 @@ export function describeCron(
60
70
  segments.push(fill(phrases.onDaysOfMonth, { days }));
61
71
  }
62
72
  if (cron.dayOfWeekRestricted) {
63
- const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, locale)));
73
+ const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, tag)));
64
74
  segments.push(fill(phrases.onWeekdays, { days }));
65
75
  }
66
76
  if (cron.months.length < 12) {
67
- const months = list.format(cron.months.map((month) => monthName(month, locale)));
77
+ const months = list.format(cron.months.map((month) => monthName(month, tag)));
68
78
  segments.push(fill(phrases.inMonths, { months }));
69
79
  }
70
80
  if (explicitTime && segments.length === 1) segments.push(phrases.everyDay);
@@ -89,13 +99,14 @@ function clockTimes(
89
99
  return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
90
100
  }
91
101
 
92
- /** `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point. */
93
- function assertLocale(locale: string): void {
94
- try {
95
- Intl.DateTimeFormat.supportedLocalesOf([locale]);
96
- } catch {
97
- throw localeInvalid(locale);
98
- }
102
+ /**
103
+ * `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point — and
104
+ * hand back the canonical spelling, so validating and keying are the same single step.
105
+ */
106
+ function assertLocale(locale: string): string {
107
+ const tag = canonicalLocale(locale);
108
+ if (tag === undefined) throw localeInvalid(locale);
109
+ return tag;
99
110
  }
100
111
 
101
112
  /** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
@@ -118,38 +129,34 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
118
129
  }
119
130
 
120
131
  /**
121
- * Hard-capped rather than key-normalised, because `locale` can arrive from an Accept-Language
122
- * header: `supportedLocalesOf` collapses unknown *tags*, but still returns a distinct string for
123
- * every unknown `-u-` extension value, so only a bound keeps the key space finite. FIFO — a `Map`
124
- * iterates in insertion order and a miss costs one `Intl` construction, not a wrong answer.
132
+ * Canonically keyed **and** hard-capped, because `locale` can arrive from an Accept-Language
133
+ * header. `canonicalLocale` collapses the spellings of one locale `EN-us`, `en-latn-us` — but it
134
+ * still returns a distinct string for every unknown `-u-` extension value, so the key alone does
135
+ * not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
136
+ * cap and its FIFO live in `@ultimat3/core`'s `intl-cache.ts`, because `zones.ts`, `format.ts` and
137
+ * `@ultimat3/money`'s formatter all need the same rule, and a hazard documented in one file is a
138
+ * hazard every other one repeats.
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 = formatterFor(monthFormatters, locale, { month: 'long', timeZone: 'UTC' });
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 = formatterFor(weekdayFormatters, locale, { weekday: 'long', timeZone: 'UTC' });
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
@@ -65,7 +65,9 @@ export function parseCron(expression: string): CronExpression {
65
65
  const hours = parseField(expression, hourField ?? '*', 0, 23);
66
66
  const daysOfMonth = parseField(expression, domField ?? '*', 1, 31);
67
67
  const months = parseField(expression, monthField ?? '*', 1, 12, MONTH_NAMES, 1);
68
- const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0);
68
+ // Span 7, not `max - min + 1` = 8: the dow field accepts 0-7 because Sunday has two spellings,
69
+ // so the modulus a wrap strides over is a week with a phantom day in it unless it is stated.
70
+ const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0, 7);
69
71
 
70
72
  // 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
71
73
  const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
@@ -113,6 +115,13 @@ function parseField(
113
115
  max: number,
114
116
  names: readonly string[] = [],
115
117
  nameOffset = 0,
118
+ /**
119
+ * How many distinct values one full turn of this field has — `max - min + 1` for every field
120
+ * whose spelling is one-to-one. Day-of-week is the exception and the reason this is a parameter:
121
+ * it spells Sunday twice (0 and 7), so its 0-7 bounds describe 8 slots over a 7-day week, and a
122
+ * wrapping stride computed from the bounds walked a day that does not exist.
123
+ */
124
+ span = max - min + 1,
116
125
  ): number[] {
117
126
  const values = new Set<number>();
118
127
  for (const part of field.split(',')) {
@@ -141,9 +150,13 @@ function parseField(
141
150
  }
142
151
 
143
152
  if (from > to) {
144
- // Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom.
145
- for (let value = from; value <= max; value += step) values.add(value);
146
- for (let value = min; value <= to; value += step) values.add(value);
153
+ // Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom, and the stride CONTINUES across
154
+ // the wrap: `23-3/2` is 23, 01, 03 every second hour starting at 23. Restarting at `min`
155
+ // answered 23, 00, 02, an hour off for every occurrence past midnight.
156
+ const length = to - from + span;
157
+ for (let offset = 0; offset <= length; offset += step) {
158
+ values.add(min + ((from - min + offset) % span));
159
+ }
147
160
  } else {
148
161
  for (let value = from; value <= to; value += step) values.add(value);
149
162
  }
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
- cause: `${field} must be ${range}, got ${String(value)}`,
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
@@ -4,6 +4,7 @@
4
4
  * because "the server's timezone" is never the answer to "what time is it for the user".
5
5
  */
6
6
 
7
+ import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
8
  import { differenceMs, type Instant } from './instant';
8
9
  import { assertTimeZone, type TimeZone } from './zones';
9
10
 
@@ -165,11 +166,17 @@ export function ordinal(value: number, locale = 'en'): string {
165
166
 
166
167
  const cache = new Map<string, Intl.DateTimeFormat>();
167
168
 
169
+ /**
170
+ * Bounded, and keyed on a zone `assertTimeZone` and a locale `canonicalLocale` have both already
171
+ * canonicalized — `Accept-Language` sends `EN-us` and `en-US` for one locale, and each spelling
172
+ * used to mint its own permanent entry. The bound stays: an unknown `-u-` extension value survives
173
+ * canonicalization as a distinct string, so only the cap keeps this key space finite.
174
+ *
175
+ * A tag `Intl` cannot parse falls through unchanged, so the `Intl.DateTimeFormat` constructor
176
+ * still raises it — this seam decides a cache key, never whether a locale is acceptable.
177
+ */
168
178
  function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
169
- const key = `${locale}|${JSON.stringify(options)}`;
170
- const cached = cache.get(key);
171
- if (cached !== undefined) return cached;
172
- const formatter = new Intl.DateTimeFormat(locale, options);
173
- cache.set(key, formatter);
174
- return formatter;
179
+ const tag = canonicalLocale(locale) ?? locale;
180
+ const key = `${tag}|${JSON.stringify(options)}`;
181
+ return cachedFormatter(cache, key, () => new Intl.DateTimeFormat(tag, options));
175
182
  }
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
- EPOCH,
85
+ epoch,
87
86
  fromEpochMs,
88
87
  fromEpochSeconds,
89
88
  fromIso,
@@ -98,6 +97,21 @@ export {
98
97
  toIso,
99
98
  toIsoDateUtc,
100
99
  } from './instant';
100
+ export {
101
+ addPlainDays,
102
+ comparePlainDates,
103
+ isPlainDate,
104
+ PLAIN_DATE_PATTERN,
105
+ type PlainDate,
106
+ type PlainDateParts,
107
+ plainDate,
108
+ plainDateIn,
109
+ plainDateOf,
110
+ plainDateParts,
111
+ plainDateToUtcInstant,
112
+ plainDateUtc,
113
+ plainDaysBetween,
114
+ } from './plain-date';
101
115
  export {
102
116
  type LocalSlot,
103
117
  nextLocalSlot,
@@ -105,6 +119,11 @@ export {
105
119
  nextWeeklySlot,
106
120
  type WeeklySlot,
107
121
  } from './schedule';
122
+ /**
123
+ * `canonicalTimeZone` is public because a zone arriving from a request header has to become one
124
+ * key before it reaches anything that caches on it — `@ultimat3/http` reads `x-timezone`.
125
+ */
126
+ export { canonicalTimeZone } from './zone-canonical';
108
127
  export {
109
128
  addDaysInZone,
110
129
  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
- /** Wrap a `Date` from an untrusted source (a DB driver, a parsed payload). */
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
- export const EPOCH: Instant = new Date(0) as Instant;
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,133 @@
1
+ // A calendar date: a year, a month and a day, with no time and therefore no zone. The framework's
2
+ // "never format a date without an IANA zone" rule is about INSTANTS — a `PlainDate` needs no zone
3
+ // because it names no instant, and that is the whole reason it exists: `effective_on` is the date
4
+ // a rate applies, not a moment, and storing it as a `timestamptz` makes it a different date either
5
+ // side of midnight for half the planet.
6
+
7
+ import { scheduleInvalid } from './errors';
8
+ import { type Instant, instant } from './instant';
9
+ import { isoDateInZone } from './zoned';
10
+ import type { TimeZone } from './zones';
11
+
12
+ declare const plainDateBrand: unique symbol;
13
+
14
+ /**
15
+ * `2026-03-14`. A branded STRING, not an object and not a `Date`, and each half of that is load-
16
+ * bearing:
17
+ *
18
+ * - not a `Date`, because a `Date` is an instant: read back through the local zone,
19
+ * `2026-03-14T00:00:00Z` is the 13th anywhere west of Greenwich, and binding one to a Postgres
20
+ * `date` parameter fails outright (`time zone "gmt-0500" not recognized`, measured on 17.10).
21
+ * - a string, because the ISO form sorts lexicographically exactly as it sorts chronologically,
22
+ * round-trips through `JSON.stringify` as itself, and is the literal Postgres accepts and
23
+ * returns for a `date` column.
24
+ */
25
+ export type PlainDate = string & { readonly [plainDateBrand]: 'plain-date' };
26
+
27
+ /** The shape a CHECK constraint and a JSON Schema both spell. ECMAScript and POSIX ERE agree. */
28
+ export const PLAIN_DATE_PATTERN = '^\\d{4}-\\d{2}-\\d{2}$';
29
+
30
+ const SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
31
+
32
+ export interface PlainDateParts {
33
+ readonly year: number;
34
+ readonly month: number;
35
+ readonly day: number;
36
+ }
37
+
38
+ const pad = (value: number, width: number): string => String(value).padStart(width, '0');
39
+
40
+ /** Days in a month, Gregorian. February is the only interesting one. */
41
+ const daysInMonth = (year: number, month: number): number =>
42
+ month === 2
43
+ ? (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
44
+ ? 29
45
+ : 28
46
+ : [4, 6, 9, 11].includes(month)
47
+ ? 30
48
+ : 31;
49
+
50
+ /**
51
+ * The parse every other function here goes through. `null` rather than a throw, so the guard and
52
+ * the thrower share one rule and only the failure policy differs — the same split
53
+ * `resolveEnvironment` / `tryResolveEnvironment` make in core.
54
+ */
55
+ const read = (value: unknown): PlainDateParts | null => {
56
+ if (typeof value !== 'string') return null;
57
+ const match = SHAPE.exec(value);
58
+ if (match === null) return null;
59
+ const year = Number(match[1]);
60
+ const month = Number(match[2]);
61
+ const day = Number(match[3]);
62
+ if (month < 1 || month > 12) return null;
63
+ // A day the month does not have is the case a regex cannot see, and it is the one that matters:
64
+ // `2026-02-30` reaches Postgres as `date` input and is rejected there, three layers later.
65
+ if (day < 1 || day > daysInMonth(year, month)) return null;
66
+ return { year, month, day };
67
+ };
68
+
69
+ export const isPlainDate = (value: unknown): value is PlainDate => read(value) !== null;
70
+
71
+ /** A calendar date from its ISO form. Throws on anything that is not one — including `2026-02-30`. */
72
+ export function plainDate(value: string): PlainDate {
73
+ const parts = read(value);
74
+ if (parts === null) {
75
+ throw scheduleInvalid('date', value, 'a real YYYY-MM-DD calendar date');
76
+ }
77
+ return value as PlainDate;
78
+ }
79
+
80
+ /** A calendar date from its fields. Out-of-range fields throw rather than wrap into another month. */
81
+ export function plainDateOf(parts: PlainDateParts): PlainDate {
82
+ return plainDate(`${pad(parts.year, 4)}-${pad(parts.month, 2)}-${pad(parts.day, 2)}`);
83
+ }
84
+
85
+ export function plainDateParts(date: PlainDate): PlainDateParts {
86
+ const parts = read(date);
87
+ if (parts === null) throw scheduleInvalid('date', date, 'a real YYYY-MM-DD calendar date');
88
+ return parts;
89
+ }
90
+
91
+ /**
92
+ * The calendar date an instant falls on **in a named zone** — the one conversion between the two,
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.
95
+ */
96
+ export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
97
+ isoDateInZone(at, zone) as PlainDate;
98
+
99
+ /**
100
+ * The calendar date a `Date` holds when read as UTC. For ONE caller: a Postgres driver hands a
101
+ * `date` column back as a `Date` at UTC midnight (measured: Bun's `sql` and PGlite both), so the
102
+ * date the column holds is its UTC date and reading it through the local zone loses a day west of
103
+ * Greenwich. Never use this on a timestamp — that is `plainDateIn`, which asks for the zone.
104
+ */
105
+ export function plainDateUtc(at: Date): PlainDate {
106
+ const checked = instant(at);
107
+ return `${pad(checked.getUTCFullYear(), 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
108
+ checked.getUTCDate(),
109
+ 2,
110
+ )}` as PlainDate;
111
+ }
112
+
113
+ /** Midnight UTC of the date, as an instant — the inverse of `plainDateUtc`, and never of `plainDateIn`. */
114
+ export const plainDateToUtcInstant = (date: PlainDate): Instant =>
115
+ instant(new Date(`${plainDate(date)}T00:00:00.000Z`));
116
+
117
+ /** `-1`, `0`, `1`. Lexicographic order IS chronological order for this form; that is why it sorts. */
118
+ export const comparePlainDates = (left: PlainDate, right: PlainDate): number =>
119
+ left < right ? -1 : left > right ? 1 : 0;
120
+
121
+ const DAY_MS = 86_400_000;
122
+
123
+ /** Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. */
124
+ export function addPlainDays(date: PlainDate, days: number): PlainDate {
125
+ if (!Number.isSafeInteger(days)) {
126
+ throw scheduleInvalid('days', days, 'a whole number of days');
127
+ }
128
+ return plainDateUtc(new Date(plainDateToUtcInstant(date).getTime() + days * DAY_MS));
129
+ }
130
+
131
+ /** Signed whole days from `from` to `to`. Always integral: both ends are UTC midnights. */
132
+ export const plainDaysBetween = (from: PlainDate, to: PlainDate): number =>
133
+ (plainDateToUtcInstant(to).getTime() - plainDateToUtcInstant(from).getTime()) / DAY_MS;
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 < 0 || value > max) {
30
- throw scheduleInvalid(field, value, `an integer 0-${String(max)}`);
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 '@ultimat3/core';
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
@@ -4,17 +4,16 @@
4
4
  * is no offset table to keep in sync and no `date-fns-tz` dependency.
5
5
  */
6
6
 
7
+ import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
8
  import { timezoneInvalid } from './errors';
8
9
  import type { Instant } from './instant';
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
- if (zone === '' || NUMERIC_OFFSET.test(zone)) return false;
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
- if (!isValidTimeZone(zone)) throw timezoneInvalid(zone);
50
- return zone;
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. */
@@ -118,48 +117,64 @@ export function zoneAbbrev(
118
117
  locale = 'en-US',
119
118
  style: 'short' | 'long' | 'shortOffset' | 'longOffset' = 'short',
120
119
  ): string {
121
- const formatter = new Intl.DateTimeFormat(locale, {
122
- timeZone: zone,
123
- timeZoneName: style,
124
- hourCycle: 'h23',
120
+ const canonical = assertTimeZone(zone);
121
+ // A tag `Intl` cannot parse falls through unchanged, exactly as in `format.ts`: this decides a
122
+ // cache key, never whether a locale is acceptable.
123
+ const tag = canonicalLocale(locale) ?? locale;
124
+ // The one `Intl` construction in this package that escaped the shared cache: it built a formatter
125
+ // per call on the caller's raw zone and locale, so an `x-timezone` an app renders a label from
126
+ // paid for a fresh `Intl.DateTimeFormat` every time and an unknown one escaped as a `RangeError`.
127
+ const formatter = cachedFormatter(labelFormatters, `${canonical}|${tag}|${style}`, () => {
128
+ return new Intl.DateTimeFormat(tag, {
129
+ timeZone: canonical,
130
+ timeZoneName: style,
131
+ hourCycle: 'h23',
132
+ });
125
133
  });
126
134
  const label = formatter.formatToParts(at).find((part) => part.type === 'timeZoneName')?.value;
127
- return label ?? offsetLabel(offsetAt(zone, at));
135
+ return label ?? offsetLabel(offsetAt(canonical, at));
128
136
  }
129
137
 
130
- /** True when the zone observes a different offset at some point in the surrounding year. */
138
+ /**
139
+ * True when the zone observes a different offset at some point in the surrounding year.
140
+ *
141
+ * Twelve probes on the FIRST of twelve consecutive months. `setUTCMonth(+n)` rolls over at month
142
+ * end — from 31 January the twelve probes land in January, March, March, May, May, July, July,
143
+ * August, October, October, December, December, so February, April, June, September and November
144
+ * are never asked, and a zone whose only transition falls in one of them reads as DST-free.
145
+ */
131
146
  export function observesDst(zone: TimeZone, at: Instant): boolean {
147
+ const start = zonePartsAt(zone, at);
132
148
  const offsets = new Set<number>();
133
149
  for (let month = 0; month < 12; month += 1) {
134
- const probe = new Date(at.getTime());
135
- probe.setUTCMonth(probe.getUTCMonth() + month);
136
- offsets.add(offsetAt(zone, probe as Instant));
150
+ const probe = new Date(utcEpoch(start.year, start.month + month, 1, 12)) as Instant;
151
+ offsets.add(offsetAt(zone, probe));
137
152
  }
138
153
  return offsets.size > 1;
139
154
  }
140
155
 
141
156
  const formatters = new Map<string, Intl.DateTimeFormat>();
157
+ const labelFormatters = new Map<string, Intl.DateTimeFormat>();
142
158
 
143
159
  function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
144
- const cached = formatters.get(zone);
145
- if (cached !== undefined) return cached;
146
- let formatter: Intl.DateTimeFormat;
147
- try {
148
- formatter = new Intl.DateTimeFormat('en-US', {
149
- timeZone: zone,
150
- hourCycle: 'h23',
151
- year: 'numeric',
152
- month: '2-digit',
153
- day: '2-digit',
154
- hour: '2-digit',
155
- minute: '2-digit',
156
- second: '2-digit',
157
- });
158
- } catch {
159
- throw timezoneInvalid(zone);
160
- }
161
- formatters.set(zone, formatter);
162
- return formatter;
160
+ // Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
161
+ const canonical = canonicalTimeZone(zone);
162
+ if (canonical === undefined) throw timezoneInvalid(zone);
163
+ return cachedFormatter(
164
+ formatters,
165
+ canonical,
166
+ () =>
167
+ new Intl.DateTimeFormat('en-US', {
168
+ timeZone: canonical,
169
+ hourCycle: 'h23',
170
+ year: 'numeric',
171
+ month: '2-digit',
172
+ day: '2-digit',
173
+ hour: '2-digit',
174
+ minute: '2-digit',
175
+ second: '2-digit',
176
+ }),
177
+ );
163
178
  }
164
179
 
165
180
  function pad2(value: number): string {