@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 +81 -0
- package/README.md +55 -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 +17 -4
- package/src/errors.ts +22 -2
- package/src/format.ts +13 -6
- package/src/index.ts +22 -3
- package/src/instant.ts +20 -3
- package/src/plain-date.ts +133 -0
- package/src/schedule.ts +8 -3
- package/src/zone-canonical.ts +54 -0
- package/src/zones.ts +55 -40
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": "
|
|
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": "
|
|
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)
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
122
|
-
* header
|
|
123
|
-
* every unknown `-u-` extension value, so
|
|
124
|
-
*
|
|
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 =
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
const
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,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 <
|
|
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 '@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
|
-
|
|
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. */
|
|
@@ -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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
135
|
+
return label ?? offsetLabel(offsetAt(canonical, at));
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
/**
|
|
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(
|
|
135
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 {
|