@ultimat3/time 29.3.2 → 30.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/README.md +14 -0
- package/package.json +14 -3
- package/src/context.ts +17 -2
- package/src/index.ts +1 -0
- package/src/zoned.ts +20 -9
- package/src/zones.ts +20 -1
package/README.md
CHANGED
|
@@ -5,6 +5,15 @@ An `Instant` is a point on the UTC timeline with no zone attached. Zones exist o
|
|
|
5
5
|
a human reads the value. No formatter in this package has an ambient default zone, and
|
|
6
6
|
none ever will — "the server's timezone" is not an answer to "what time is it for the user".
|
|
7
7
|
|
|
8
|
+
`bun add @ultimat3/time` · guide: [Timezones And Dates](https://github.com/developerz-ai/ultimate/wiki/Timezones-And-Dates)
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { formatDateTime, fromIso } from '@ultimat3/time';
|
|
12
|
+
|
|
13
|
+
formatDateTime(fromIso('2026-03-14T08:00:00Z'), { locale: 'en-GB', zone: 'Europe/Berlin' });
|
|
14
|
+
// '14 Mar 2026, 09:00:00' — no zone, no format: there is no ambient default
|
|
15
|
+
```
|
|
16
|
+
|
|
8
17
|
| Concern | Store | Format / compute |
|
|
9
18
|
|---|---|---|
|
|
10
19
|
| Instants | UTC (`timestamptz`, ISO-8601 `Z`) | `Intl.DateTimeFormat` + explicit `timeZone` |
|
|
@@ -21,6 +30,11 @@ answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolve
|
|
|
21
30
|
return it. Anything reading a zone off a request header should canonicalize before caching on it:
|
|
22
31
|
4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB.
|
|
23
32
|
|
|
33
|
+
**The request's zone, in order:** `user`, `cookie` (a picker's `x_timezone`), `query` (`?tz=`),
|
|
34
|
+
`header` (`x-timezone`), `detected` (`TIMEZONE_DETECTED_COOKIE`, `x_timezone_detected`), then the
|
|
35
|
+
`configureTime` default. `detected` is what `@ultimat3/render`'s `zoneScript` writes on `app/`
|
|
36
|
+
documents under `time: { detect: true }`: a guess, below everything chosen, never written over it.
|
|
37
|
+
|
|
24
38
|
**A zone is `Area/Location`, or `UTC`.** Nothing else. `CET`, `EST5EDT` and `+02:00` name no
|
|
25
39
|
jurisdiction and carry no DST rule; the single-label `backward` links (`Japan`, `GB`, `Eire`) are
|
|
26
40
|
refused with them, because no rule keeps the first group out and lets the second in. Write the
|
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.0.0",
|
|
4
4
|
"description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"time",
|
|
10
|
+
"timezone",
|
|
11
|
+
"cron",
|
|
12
|
+
"dates",
|
|
13
|
+
"intl"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Timezones-And-Dates",
|
|
5
16
|
"license": "MIT",
|
|
6
17
|
"type": "module",
|
|
7
18
|
"sideEffects": [
|
|
@@ -35,7 +46,7 @@
|
|
|
35
46
|
"test": "bun test"
|
|
36
47
|
},
|
|
37
48
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
49
|
+
"@ultimat3/core": "30.0.0",
|
|
50
|
+
"@ultimat3/schema": "30.0.0"
|
|
40
51
|
}
|
|
41
52
|
}
|
package/src/context.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { assertTimeZone, type TimeZone, UTC } from './zones';
|
|
|
11
11
|
/** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
|
|
12
12
|
export const TIMEZONE_HEADER = 'x-timezone';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Cookie `@ultimat3/render`'s zone script writes from the same `Intl` call, on `app/` documents: a
|
|
16
|
+
* document navigation sends no header, so without it a new visitor's first pages render in UTC.
|
|
17
|
+
* Never the chosen cookie — a guess written there would erase the visitor's own pick.
|
|
18
|
+
*/
|
|
19
|
+
export const TIMEZONE_DETECTED_COOKIE = 'x_timezone_detected';
|
|
20
|
+
|
|
14
21
|
export interface TimeZoneSources {
|
|
15
22
|
/** `user.timeZone` — an explicit preference beats a browser guess. */
|
|
16
23
|
user?: string | null;
|
|
@@ -20,6 +27,8 @@ export interface TimeZoneSources {
|
|
|
20
27
|
query?: string | null;
|
|
21
28
|
/** `x-timezone` request header. */
|
|
22
29
|
header?: string | null;
|
|
30
|
+
/** `x_timezone_detected`, the zone script's guess — below the header a client sent on purpose. */
|
|
31
|
+
detected?: string | null;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export type TimeZoneSourceName = keyof TimeZoneSources;
|
|
@@ -39,9 +48,15 @@ export interface TimeConfig {
|
|
|
39
48
|
* Explicit before inferred, the same rule `@ultimat3/i18n`'s locale order states: `Accept-Language`
|
|
40
49
|
* and `x-timezone` are what the browser was installed as, while the user row, the cookie and the
|
|
41
50
|
* query are what a person *chose*. A zone picker that wrote a cookie the header always outranked
|
|
42
|
-
* would appear to do nothing.
|
|
51
|
+
* would appear to do nothing. `detected` is last: a script's guess, written with no request asking.
|
|
43
52
|
*/
|
|
44
|
-
const DEFAULT_ORDER: readonly TimeZoneSourceName[] = [
|
|
53
|
+
const DEFAULT_ORDER: readonly TimeZoneSourceName[] = [
|
|
54
|
+
'user',
|
|
55
|
+
'cookie',
|
|
56
|
+
'query',
|
|
57
|
+
'header',
|
|
58
|
+
'detected',
|
|
59
|
+
];
|
|
45
60
|
|
|
46
61
|
let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
|
|
47
62
|
|
package/src/index.ts
CHANGED
package/src/zoned.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
assertTimeZone,
|
|
10
10
|
offsetAt,
|
|
11
11
|
offsetLabel,
|
|
12
|
+
offsetSecondsAt,
|
|
12
13
|
type TimeZone,
|
|
13
14
|
utcEpoch,
|
|
14
15
|
zonePartsAt,
|
|
@@ -91,7 +92,8 @@ export function toZoned(at: Instant, zone: TimeZone): ZonedDateTime {
|
|
|
91
92
|
* - exactly one → normal case
|
|
92
93
|
* - none → the hour was skipped (gap)
|
|
93
94
|
*
|
|
94
|
-
* Milliseconds ride along untouched:
|
|
95
|
+
* Milliseconds ride along untouched: an offset is whole seconds, the search is in seconds, and a
|
|
96
|
+
* zone off the minute (Africa/Monrovia until 1972) is searched at its real offset.
|
|
95
97
|
*/
|
|
96
98
|
export function fromZonedDetailed(
|
|
97
99
|
wall: WallClock,
|
|
@@ -114,11 +116,11 @@ export function fromZonedDetailed(
|
|
|
114
116
|
);
|
|
115
117
|
|
|
116
118
|
const probes = [target - 86_400_000, target, target + 86_400_000];
|
|
117
|
-
const offsets = new Set(probes.map((ms) =>
|
|
119
|
+
const offsets = new Set(probes.map((ms) => offsetSecondsAt(zone, new Date(ms) as Instant)));
|
|
118
120
|
|
|
119
121
|
const verified: number[] = [];
|
|
120
|
-
for (const
|
|
121
|
-
const candidate = target -
|
|
122
|
+
for (const offsetSeconds of offsets) {
|
|
123
|
+
const candidate = target - offsetSeconds * 1000;
|
|
122
124
|
if (matchesWall(candidate, zone, normal)) verified.push(candidate);
|
|
123
125
|
}
|
|
124
126
|
const distinct = [...new Set(verified)].sort((a, b) => a - b);
|
|
@@ -136,7 +138,7 @@ export function fromZonedDetailed(
|
|
|
136
138
|
// the smaller lands before the transition, the larger after it.
|
|
137
139
|
if (gap === 'throw') throw dstNonexistent(describeWall(normal), zone);
|
|
138
140
|
const bracket = [...offsets]
|
|
139
|
-
.map((
|
|
141
|
+
.map((offsetSeconds) => target - offsetSeconds * 1000)
|
|
140
142
|
.sort((a, b) => a - b);
|
|
141
143
|
const chosen = gap === 'next' ? bracket[bracket.length - 1] : bracket[0];
|
|
142
144
|
return finish(chosen ?? target, millisecond, zone, 'gap');
|
|
@@ -184,6 +186,9 @@ export function addDaysInZone(at: Instant, days: number, zone: TimeZone): Instan
|
|
|
184
186
|
if (!Number.isSafeInteger(days)) {
|
|
185
187
|
throw scheduleInvalid('days', days, 'a whole number of days');
|
|
186
188
|
}
|
|
189
|
+
// Identity, and not a round trip: the wall clock of a repeated hour names two instants, and
|
|
190
|
+
// reading this one back answered the first pass whichever one `at` was.
|
|
191
|
+
if (days === 0) return at;
|
|
187
192
|
const zoned = toZoned(at, zone);
|
|
188
193
|
return fromZoned(
|
|
189
194
|
{
|
|
@@ -211,12 +216,18 @@ export function isoDateInZone(at: Instant, zone: TimeZone): string {
|
|
|
211
216
|
* (which is what `toIso` is for). The form a schedule is read in: the two passes of a fall-back
|
|
212
217
|
* hour are one wall time and two instants, and the offset is the only thing that tells them
|
|
213
218
|
* apart. Seconds precision; `fromIso` reads it back to the same second.
|
|
219
|
+
*
|
|
220
|
+
* An offset off the minute (Africa/Monrovia, -00:44:30 until 1972) has no ISO-8601 spelling — an
|
|
221
|
+
* offset is `±HH:MM`, and `fromIso` refuses more — so the label rounds and the clock written is the
|
|
222
|
+
* one THAT label names: 30 s from the zone's own dial, never 30 s from the instant.
|
|
214
223
|
*/
|
|
215
224
|
export function isoInZone(at: Instant, zone: TimeZone): string {
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
225
|
+
const offsetMinutes = toZoned(at, zone).offsetMinutes;
|
|
226
|
+
const clock = new Date(Math.floor(at.getTime() / 1000) * 1000 + offsetMinutes * 60_000);
|
|
227
|
+
const year = String(clock.getUTCFullYear()).padStart(4, '0');
|
|
228
|
+
const date = `${year}-${pad2(clock.getUTCMonth() + 1)}-${pad2(clock.getUTCDate())}`;
|
|
229
|
+
const time = `${pad2(clock.getUTCHours())}:${pad2(clock.getUTCMinutes())}:${pad2(clock.getUTCSeconds())}`;
|
|
230
|
+
return `${date}T${time}${offsetLabel(offsetMinutes)}`;
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
/** True when both instants fall on the same local calendar day. */
|
package/src/zones.ts
CHANGED
|
@@ -102,10 +102,29 @@ export function offsetAt(zone: TimeZone, at: Instant): number {
|
|
|
102
102
|
parts.minute,
|
|
103
103
|
parts.second,
|
|
104
104
|
);
|
|
105
|
-
//
|
|
105
|
+
// Rounded for a label: modern offsets are whole minutes, a historic one is `offsetSecondsAt`'s.
|
|
106
106
|
return Math.round((asIfUtc - at.getTime()) / 60_000);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The same difference in SECONDS, unrounded: what an instant is computed from. A zone's history
|
|
111
|
+
* holds offsets off the minute (Africa/Monrovia was -00:44:30 until 1972), and rounding one moved
|
|
112
|
+
* every instant built from it. Internal: a label and `offsetMinutes` are the public reading.
|
|
113
|
+
*/
|
|
114
|
+
export function offsetSecondsAt(zone: TimeZone, at: Instant): number {
|
|
115
|
+
const parts = zonePartsAt(zone, at);
|
|
116
|
+
const asIfUtc = utcEpoch(
|
|
117
|
+
parts.year,
|
|
118
|
+
parts.month,
|
|
119
|
+
parts.day,
|
|
120
|
+
parts.hour,
|
|
121
|
+
parts.minute,
|
|
122
|
+
parts.second,
|
|
123
|
+
);
|
|
124
|
+
// Whole seconds on both sides: the wall clock above has dropped the milliseconds.
|
|
125
|
+
return (asIfUtc - Math.floor(at.getTime() / 1000) * 1000) / 1000;
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
/** `+01:00`, `-04:00`, `+05:45`, `Z`. */
|
|
110
129
|
export function offsetLabel(minutes: number): string {
|
|
111
130
|
if (minutes === 0) return 'Z';
|