@ultimat3/time 29.4.0 → 30.1.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 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": "29.4.0",
3
+ "version": "30.1.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": "29.4.0",
39
- "@ultimat3/schema": "29.4.0"
49
+ "@ultimat3/core": "30.1.0",
50
+ "@ultimat3/schema": "30.1.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[] = ['user', 'cookie', 'query', 'header'];
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
@@ -20,6 +20,7 @@ export {
20
20
  configureTime,
21
21
  currentTimeZone,
22
22
  resolveTimeZone,
23
+ TIMEZONE_DETECTED_COOKIE,
23
24
  TIMEZONE_HEADER,
24
25
  type TimeConfig,
25
26
  type TimeZoneResolution,
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: every real offset is a whole number of minutes.
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) => offsetAt(zone, new Date(ms) as Instant)));
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 offsetMinutes of offsets) {
121
- const candidate = target - offsetMinutes * 60_000;
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((offsetMinutes) => target - offsetMinutes * 60_000)
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 zoned = toZoned(at, zone);
217
- const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
218
- const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
219
- return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
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
- // Offsets are whole minutes; rounding absorbs the sub-second part `asIfUtc` drops.
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';