@ultimat3/time 2.0.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 +20 -3
- package/README.md +32 -2
- package/package.json +2 -2
- package/src/cron-describe.ts +4 -4
- package/src/cron-parse.ts +10 -2
- package/src/format.ts +1 -2
- package/src/index.ts +15 -0
- package/src/plain-date.ts +133 -0
- package/src/zone-canonical.ts +1 -1
- package/src/zones.ts +16 -6
- package/src/intl-cache.ts +0 -26
- package/src/locale-canonical.ts +0 -23
package/CLAUDE.md
CHANGED
|
@@ -10,8 +10,6 @@
|
|
|
10
10
|
| `instant.ts` | the UTC `Instant` brand, ISO/epoch conversion, `now(clock)`, `epoch()` |
|
|
11
11
|
| `zones.ts` | IANA validation, `offsetAt` (minutes east), zone labels |
|
|
12
12
|
| `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
|
|
13
|
-
| `locale-canonical.ts` | one locale, one key: `canonicalLocale` — the same collapse for the `Accept-Language` half |
|
|
14
|
-
| `intl-cache.ts` | the one bounded FIFO every `Intl` formatter cache in this package uses |
|
|
15
13
|
| `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
|
|
16
14
|
| `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
|
|
17
15
|
| `duration.ts` | `'2h30m'` ⇄ ms |
|
|
@@ -22,6 +20,7 @@
|
|
|
22
20
|
| `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
|
|
23
21
|
| `business.ts` | weekends as config, holidays as local dates |
|
|
24
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 |
|
|
25
24
|
|
|
26
25
|
## Rules
|
|
27
26
|
|
|
@@ -35,7 +34,10 @@
|
|
|
35
34
|
possible version of the rule above. Never reintroduce either half.
|
|
36
35
|
- **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
|
|
37
36
|
a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
|
|
38
|
-
for a locale) and the cache must be bounded (`cachedFormatter
|
|
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
|
|
39
41
|
`Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
|
|
40
42
|
of a 13-letter zone is 2^12. **Both halves, always** — a canonical key does not bound anything
|
|
41
43
|
(an unknown `-u-` extension value survives canonicalization as a distinct string) and the cap
|
|
@@ -50,6 +52,21 @@
|
|
|
50
52
|
no seconds phrase, so a 6-field expression with a non-trivial seconds field is
|
|
51
53
|
`X_CRON_NOT_DESCRIBABLE`. Adding a required field to `CronPhrases` would break every caller
|
|
52
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.
|
|
53
70
|
- Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
|
|
54
71
|
- Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
|
|
55
72
|
- Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
|
package/README.md
CHANGED
|
@@ -23,8 +23,10 @@ return it. Anything reading a zone off a request header should canonicalize befo
|
|
|
23
23
|
|
|
24
24
|
One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`,
|
|
25
25
|
`en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they
|
|
26
|
-
reach a formatter cache. The cap
|
|
27
|
-
|
|
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.
|
|
28
30
|
|
|
29
31
|
Every value this package hands back is its own object: `instant(date)` copies rather than branding
|
|
30
32
|
the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
|
|
@@ -78,6 +80,34 @@ one of these takes the zone explicitly, and none of them has a default.
|
|
|
78
80
|
`daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
|
|
79
81
|
and 24 real hours inside a 25-hour fall-back day is `0`.
|
|
80
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
|
+
|
|
81
111
|
## Cron
|
|
82
112
|
|
|
83
113
|
`parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
|
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",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "3.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/cron-describe.ts
CHANGED
|
@@ -4,10 +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
9
|
import { cronNotDescribable, localeInvalid } from './errors';
|
|
9
|
-
import { cachedFormatter } from './intl-cache';
|
|
10
|
-
import { canonicalLocale } from './locale-canonical';
|
|
11
10
|
|
|
12
11
|
export interface CronPhrases {
|
|
13
12
|
everyMinute: string;
|
|
@@ -134,8 +133,9 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
|
|
|
134
133
|
* header. `canonicalLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
|
|
135
134
|
* still returns a distinct string for every unknown `-u-` extension value, so the key alone does
|
|
136
135
|
* not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
|
|
137
|
-
* cap and its FIFO live in `intl-cache.ts`, because `zones.ts
|
|
138
|
-
* rule and a hazard documented in one file is a
|
|
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
139
|
*
|
|
140
140
|
* Both caches are fed the canonical `tag` by `describeCron` alone, never a caller string.
|
|
141
141
|
*/
|
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(',')) {
|
|
@@ -144,7 +153,6 @@ function parseField(
|
|
|
144
153
|
// Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom, and the stride CONTINUES across
|
|
145
154
|
// the wrap: `23-3/2` is 23, 01, 03 — every second hour starting at 23. Restarting at `min`
|
|
146
155
|
// answered 23, 00, 02, an hour off for every occurrence past midnight.
|
|
147
|
-
const span = max - min + 1;
|
|
148
156
|
const length = to - from + span;
|
|
149
157
|
for (let offset = 0; offset <= length; offset += step) {
|
|
150
158
|
values.add(min + ((from - min + offset) % span));
|
package/src/format.ts
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
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
|
-
import { cachedFormatter } from './intl-cache';
|
|
9
|
-
import { canonicalLocale } from './locale-canonical';
|
|
10
9
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
11
10
|
|
|
12
11
|
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,21 @@ export {
|
|
|
97
97
|
toIso,
|
|
98
98
|
toIsoDateUtc,
|
|
99
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';
|
|
100
115
|
export {
|
|
101
116
|
type LocalSlot,
|
|
102
117
|
nextLocalSlot,
|
|
@@ -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/zone-canonical.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A 13-letter name has 2^12 casings and a request header can name any of them.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { cachedFormatter } from '
|
|
7
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
|
|
9
9
|
/** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
|
|
10
10
|
const NUMERIC_OFFSET = /^[+-]/;
|
package/src/zones.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
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';
|
|
9
|
-
import { cachedFormatter } from './intl-cache';
|
|
10
10
|
import { canonicalTimeZone } from './zone-canonical';
|
|
11
11
|
|
|
12
12
|
/** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
|
|
@@ -117,13 +117,22 @@ export function zoneAbbrev(
|
|
|
117
117
|
locale = 'en-US',
|
|
118
118
|
style: 'short' | 'long' | 'shortOffset' | 'longOffset' = 'short',
|
|
119
119
|
): string {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
});
|
|
124
133
|
});
|
|
125
134
|
const label = formatter.formatToParts(at).find((part) => part.type === 'timeZoneName')?.value;
|
|
126
|
-
return label ?? offsetLabel(offsetAt(
|
|
135
|
+
return label ?? offsetLabel(offsetAt(canonical, at));
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
/**
|
|
@@ -145,6 +154,7 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
|
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
157
|
+
const labelFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
148
158
|
|
|
149
159
|
function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
150
160
|
// Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
|
package/src/intl-cache.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One bounded cache for every `Intl` formatter this package builds.
|
|
3
|
-
* A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on that
|
|
4
|
-
* string is memory the client chooses: 4,096 case-variants of one zone name retained 31 MB,
|
|
5
|
-
* ~7.7 KB per `Intl.DateTimeFormat`, and 600 zones times 2^12 casings has no ceiling at all.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
|
|
10
|
-
* and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
|
|
11
|
-
* `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
|
|
12
|
-
*/
|
|
13
|
-
export const MAX_CACHED_FORMATTERS = 512;
|
|
14
|
-
|
|
15
|
-
/** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
|
|
16
|
-
export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
|
|
17
|
-
const hit = cache.get(key);
|
|
18
|
-
if (hit !== undefined) return hit;
|
|
19
|
-
const formatter = build();
|
|
20
|
-
if (cache.size >= MAX_CACHED_FORMATTERS) {
|
|
21
|
-
const oldest = cache.keys().next().value;
|
|
22
|
-
if (oldest !== undefined) cache.delete(oldest);
|
|
23
|
-
}
|
|
24
|
-
cache.set(key, formatter);
|
|
25
|
-
return formatter;
|
|
26
|
-
}
|
package/src/locale-canonical.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One locale, one key. `Intl` accepts `EN-us`, `en-US` and `en-latn-us` as the same locale, so a
|
|
3
|
-
* cache keyed on the caller's spelling holds three formatters where one would do — and the caller
|
|
4
|
-
* is `Accept-Language`. The twin of `zone-canonical.ts`, for the other header-supplied string.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
9
|
-
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
10
|
-
* falls back for it, and refusing here would be stricter than the formatters this feeds.
|
|
11
|
-
*
|
|
12
|
-
* Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
|
|
13
|
-
* unbounded cache the whole `intl-cache.ts` bound exists to prevent.
|
|
14
|
-
*/
|
|
15
|
-
export function canonicalLocale(locale: string): string | undefined {
|
|
16
|
-
try {
|
|
17
|
-
// `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
|
|
18
|
-
// `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
|
|
19
|
-
return Intl.getCanonicalLocales(locale)[0];
|
|
20
|
-
} catch {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
}
|