@ultimat3/time 30.8.0 → 31.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 CHANGED
@@ -19,6 +19,8 @@
19
19
  | `cron-describe.ts` | `describeCron` — `Intl` names, phrases injected. `CronPhrases` is required. |
20
20
  | `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
21
21
  | `business.ts` | weekends as config, holidays as local dates |
22
+ | `business-span.ts` | business-day arithmetic on epoch days: weekday by `mod 7`, whole weeks multiplied, holidays binary-searched — no zone, no `Intl` |
23
+ | `zone-skipped-days.ts` | `localDayAt` and `skippedDays` — the dates a zone never had at a wall time, found by sampling offsets |
22
24
  | `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
23
25
  | `plain-date.ts` | `PlainDate` — a calendar date with no time and no zone, and the two conversions to an instant |
24
26
  | `plain-date-range.ts` | `plainDateRange` — the bounded, frozen list of dates between two `PlainDate`s |
@@ -160,9 +162,20 @@
160
162
  match", not "is this an occurrence".
161
163
  - **`addBusinessDays` builds every candidate from the calendar date + the ORIGINAL wall time.**
162
164
  Chained through `addDaysInZone`, a 02:30 that crossed a spring-forward Sunday became 03:30 and
163
- stayed 03:30 on Monday. `businessDaysBetween` walks the same dates (`localDay`): a chain steps
165
+ stayed 03:30 on Monday. `businessDaysBetween` reads the same dates (`localDayAt`): a chain steps
164
166
  OVER a date the zone never had (Samoa, 2011-12-30) and so counted the day `to` excludes. A date
165
167
  that does not exist is not a day of either kind.
168
+ - **Business days are counted arithmetically, never walked, `As of 2026-11`.** The walk asked
169
+ `Intl` 2–3 times per DAY: 30 years of `businessDaysBetween` was ~830 ms, `addBusinessDays(…, 5000)`
170
+ ~500 ms. Now whole weeks multiply (`business-span.ts`), holidays are a sorted binary search, and
171
+ `Intl` is asked only for offset samples (`zone-skipped-days.ts`, one per 28 days) plus the dates
172
+ near a transition that could remove one. The 28-day stride rests on a MEASUREMENT (every zone,
173
+ 1900–2050): transitions under 28 days apart are all 1-hour shifts, which move a date only for a
174
+ wall time from 23:00 — where the stride drops to 6 days (closest pair: 7.0). After a tzdata
175
+ change that adds a short-lived rule, re-measure: sample every zone's offset twice a day and list
176
+ consecutive transitions under 28 days apart.
177
+ `business-linear.test.ts` keeps the old walk as the ORACLE over random zones, walls, weekends and
178
+ holidays; never delete it to make a change pass.
166
179
  - **`formatRelative` takes `zone`, and from a day apart counts CALENDAR days** (`daysBetween`).
167
180
  `trunc(47h / 24h)` called two midnights away "tomorrow". Under a day it is elapsed time; a full
168
181
  day apart with no midnight crossed (a 25-hour day) is hours, not "today". **Breaking.**
@@ -217,6 +230,6 @@
217
230
  ## Commands
218
231
 
219
232
  ```
220
- bun test packages/time
233
+ bun test ./packages/time/
221
234
  bun run --filter @ultimat3/time typecheck
222
235
  ```
package/README.md CHANGED
@@ -30,7 +30,7 @@ answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolve
30
30
  return it. Anything reading a zone off a request header should canonicalize before caching on it:
31
31
  4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB.
32
32
 
33
- **The request's zone, in order:** `user`, `cookie` (a picker's `x_timezone`), `query` (`?tz=`),
33
+ **The request's zone, in order:** `user`, `cookie` (`TIMEZONE_COOKIE`, a picker's `x_timezone`), `query` (`?tz=`),
34
34
  `header` (`x-timezone`), `detected` (`TIMEZONE_DETECTED_COOKIE`, `x_timezone_detected`), then the
35
35
  `configureTime` default. `detected` is what `@ultimat3/render`'s `zoneScript` writes on `app/`
36
36
  documents under `time: { detect: true }`: a guess, below everything chosen, never written over it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "30.8.0",
3
+ "version": "31.0.0",
4
4
  "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -46,7 +46,7 @@
46
46
  "test": "bun test"
47
47
  },
48
48
  "dependencies": {
49
- "@ultimat3/core": "30.8.0",
50
- "@ultimat3/schema": "30.8.0"
49
+ "@ultimat3/core": "31.0.0",
50
+ "@ultimat3/schema": "31.0.0"
51
51
  }
52
52
  }
@@ -0,0 +1,115 @@
1
+ // Single responsibility: business-day arithmetic on CALENDAR DAYS — epoch day numbers, no zone and
2
+ // no `Intl`. A weekday is `day mod 7`, a run of whole weeks is multiplication, and a holiday is a
3
+ // sorted number searched in O(log n): what lets a 30-year span cost what a week did.
4
+
5
+ import { holidaySet } from './holidays';
6
+
7
+ export const DAY_MS = 86_400_000;
8
+
9
+ /** 1970-01-01, epoch day 0, was a Thursday: ISO weekday 4. */
10
+ const EPOCH_WEEKDAY = 4;
11
+
12
+ /** ISO weekday (1 = Monday … 7 = Sunday) of an epoch day, for any sign. */
13
+ export function weekdayOfDay(day: number): number {
14
+ return ((((day + EPOCH_WEEKDAY - 1) % 7) + 7) % 7) + 1;
15
+ }
16
+
17
+ /** The epoch day a UTC midnight epoch names. */
18
+ export function dayOfEpoch(epochMs: number): number {
19
+ return Math.round(epochMs / DAY_MS);
20
+ }
21
+
22
+ /**
23
+ * `isWorkday[weekday]` for ISO weekdays 1–7, from the weekend list exactly as `includes` reads it:
24
+ * a stray entry (`0`, `8`) matches no weekday, as it never did.
25
+ */
26
+ export function workdayTable(weekend: readonly number[]): readonly boolean[] {
27
+ const table = [false];
28
+ for (let weekday = 1; weekday <= 7; weekday += 1) table.push(!weekend.includes(weekday));
29
+ return table;
30
+ }
31
+
32
+ /** Workdays among the epoch days `[from, to)`: whole weeks multiplied, the remainder walked. */
33
+ export function workdaysIn(from: number, to: number, isWorkday: readonly boolean[]): number {
34
+ if (to <= from) return 0;
35
+ const perWeek = isWorkday.filter(Boolean).length;
36
+ const weeks = Math.floor((to - from) / 7);
37
+ let count = weeks * perWeek;
38
+ for (let day = from + weeks * 7; day < to; day += 1) {
39
+ if (isWorkday[weekdayOfDay(day)]) count += 1;
40
+ }
41
+ return count;
42
+ }
43
+
44
+ /**
45
+ * The distance `t >= 1` from `origin` (in `step`'s direction) of its `k`th workday, `k >= 1`.
46
+ * The table holds at least one workday — the caller refused an all-weekend calendar first.
47
+ */
48
+ export function nthWorkday(
49
+ origin: number,
50
+ k: number,
51
+ step: 1 | -1,
52
+ isWorkday: readonly boolean[],
53
+ ): number {
54
+ const perWeek = isWorkday.filter(Boolean).length;
55
+ const weeks = Math.floor((k - 1) / perWeek);
56
+ let remaining = k - weeks * perWeek;
57
+ let distance = weeks * 7;
58
+ while (remaining > 0) {
59
+ distance += 1;
60
+ if (isWorkday[weekdayOfDay(origin + step * distance)]) remaining -= 1;
61
+ }
62
+ return distance;
63
+ }
64
+
65
+ /** `isoDateInZone`'s spelling of an epoch day: the form a calendar's holidays are written in. */
66
+ export function isoOfDay(day: number): string {
67
+ const at = new Date(day * DAY_MS);
68
+ const pad2 = (value: number): string => String(value).padStart(2, '0');
69
+ return `${String(at.getUTCFullYear()).padStart(4, '0')}-${pad2(at.getUTCMonth() + 1)}-${pad2(at.getUTCDate())}`;
70
+ }
71
+
72
+ const ISO_DAY = /^(\d{4,})-(\d{2})-(\d{2})$/;
73
+
74
+ /** Holiday epoch days, sorted, per holiday SET — `holidaySet` returns one set per list. */
75
+ const holidayDayMemo = new WeakMap<ReadonlySet<string>, readonly number[]>();
76
+
77
+ /**
78
+ * A calendar's holidays as sorted epoch days. A string is kept only when it round-trips through
79
+ * `isoOfDay` — exactly the strings the per-day walk could ever have matched, so `2026-02-30` and
80
+ * `2026-3-1` stay the no-ops they always were rather than rolling onto a real day.
81
+ */
82
+ export function holidayDays(holidays: readonly string[] | undefined): readonly number[] {
83
+ const set = holidaySet(holidays);
84
+ const hit = holidayDayMemo.get(set);
85
+ if (hit !== undefined) return hit;
86
+ const days: number[] = [];
87
+ for (const iso of set) {
88
+ const match = typeof iso === 'string' ? ISO_DAY.exec(iso) : null;
89
+ if (match === null) continue;
90
+ const at = new Date(0);
91
+ at.setUTCFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
92
+ const day = dayOfEpoch(at.getTime());
93
+ if (isoOfDay(day) === iso) days.push(day);
94
+ }
95
+ days.sort((left, right) => left - right);
96
+ holidayDayMemo.set(set, days);
97
+ return days;
98
+ }
99
+
100
+ /** The first index in a sorted list whose value is `>= value`. */
101
+ function lowerBound(sorted: readonly number[], value: number): number {
102
+ let low = 0;
103
+ let high = sorted.length;
104
+ while (low < high) {
105
+ const mid = (low + high) >>> 1;
106
+ if ((sorted[mid] ?? 0) < value) low = mid + 1;
107
+ else high = mid;
108
+ }
109
+ return low;
110
+ }
111
+
112
+ /** How many values of a sorted list lie in `[from, to)`. */
113
+ export function countIn(sorted: readonly number[], from: number, to: number): number {
114
+ return to <= from ? 0 : lowerBound(sorted, to) - lowerBound(sorted, from);
115
+ }
package/src/business.ts CHANGED
@@ -3,10 +3,20 @@
3
3
  * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
4
  */
5
5
 
6
+ import {
7
+ countIn,
8
+ dayOfEpoch,
9
+ holidayDays,
10
+ nthWorkday,
11
+ weekdayOfDay,
12
+ workdaysIn,
13
+ workdayTable,
14
+ } from './business-span';
6
15
  import { scheduleInvalid, type TimeError } from './errors';
7
16
  import { holidaySet } from './holidays';
8
17
  import type { Instant } from './instant';
9
- import { daysBetween, fromZoned, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
18
+ import { localDayAt, skippedDays } from './zone-skipped-days';
19
+ import { daysBetween, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
10
20
  import { type TimeZone, utcEpoch } from './zones';
11
21
 
12
22
  /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
@@ -64,28 +74,55 @@ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCal
64
74
  throw noBusinessDay(calendar);
65
75
  }
66
76
  const step = days > 0 ? 1 : -1;
67
- const origin = toZoned(at, calendar.zone);
68
- let remaining = Math.abs(days);
69
- let offset = 0;
70
- let guard = 0;
71
- let landed = at;
72
-
73
- while (remaining > 0) {
74
- offset += step;
75
- const candidate = localDay(origin, offset, calendar.zone);
76
- if (candidate !== null && isBusinessDay(candidate, calendar)) {
77
- remaining -= 1;
78
- landed = candidate;
77
+ const wanted = Math.abs(days);
78
+ const zoned = toZoned(at, calendar.zone);
79
+ const origin = epochDayOf(zoned);
80
+ const isWorkday = workdayTable(weekend);
81
+ const holidays = holidayDays(calendar.holidays).filter((day) => isWorkday[weekdayOfDay(day)]);
82
+ // Ten years past the last day a full week could need without one business day: the calendar has
83
+ // none to give. The guard used to RETURN the day it stopped on, a weekend or a holiday — which a
84
+ // caller schedules against as if it were the business day it asked for.
85
+ const guard = wanted * 7 + 3650;
86
+ // Workdays the zone never had at this wall time, gathered as the span grows: `[1, scanned]` days out.
87
+ const skipped: number[] = [];
88
+ let scanned = 0;
89
+ // The smallest distance with `wanted` business days in `(origin, origin + distance]`: start at
90
+ // the `wanted`th workday and move to the `(wanted + excluded)`th until the excluded count stops
91
+ // growing — each step stays at or below the answer, so the fixed point IS the answer.
92
+ let distance = nthWorkday(origin, wanted, step, isWorkday);
93
+ for (;;) {
94
+ if (distance > guard) throw noBusinessDay(calendar);
95
+ if (distance > scanned) {
96
+ const [low, high] = span(origin, scanned + 1, distance + 1, step);
97
+ for (const day of skippedDays(calendar.zone, zoned, low, high)) {
98
+ if (isWorkday[weekdayOfDay(day)] && countIn(holidays, day, day + 1) === 0)
99
+ skipped.push(day);
100
+ }
101
+ skipped.sort((left, right) => left - right);
102
+ scanned = distance;
79
103
  }
80
- guard += 1;
81
- // Ten years past the last day a full week could need without one business day: the calendar has
82
- // none to give. It used to RETURN here — the day the guard stopped on, a weekend or a holiday —
83
- // which a caller schedules against as if it were the business day it asked for.
84
- if (guard > Math.abs(days) * 7 + 3650) throw noBusinessDay(calendar);
104
+ const [low, high] = span(origin, 1, distance + 1, step);
105
+ const excluded = countIn(holidays, low, high) + countIn(skipped, low, high);
106
+ const next = nthWorkday(origin, wanted + excluded, step, isWorkday);
107
+ if (next === distance) break;
108
+ distance = next;
85
109
  }
110
+ const landed = localDayAt(origin + step * distance, zoned, calendar.zone);
111
+ // `skippedDays` found every date this zone lacks; a `null` here is a tzdata it was not measured on.
112
+ if (landed === null) throw noBusinessDay(calendar);
86
113
  return landed;
87
114
  }
88
115
 
116
+ /** The epoch days `[low, high)` that distances `[near, far)` from `origin` cover, in `step`'s direction. */
117
+ function span(origin: number, near: number, far: number, step: 1 | -1): [number, number] {
118
+ return step === 1 ? [origin + near, origin + far] : [origin - far + 1, origin - near + 1];
119
+ }
120
+
121
+ /** The local calendar date of a zoned value, as an epoch day. */
122
+ function epochDayOf(zoned: ZonedDateTime): number {
123
+ return dayOfEpoch(utcEpoch(zoned.year, zoned.month, zoned.day));
124
+ }
125
+
89
126
  /**
90
127
  * The refusal for a calendar the loop above can never land in. Which list is at fault decides the
91
128
  * repair: a weekend covering all seven days, or — the weekend leaving days — holidays covering them.
@@ -106,38 +143,6 @@ function noBusinessDay(calendar: BusinessCalendar): TimeError {
106
143
  );
107
144
  }
108
145
 
109
- /**
110
- * The calendar date `offset` days from `origin`'s, at `origin`'s wall time — or `null` when the
111
- * zone never had that date (Samoa's 2011-12-30).
112
- *
113
- * Built from the calendar date and the ORIGINAL wall time, never from the day before it. Chaining
114
- * `addDaysInZone` a day at a time carried a DST repair forward: 02:30 through a spring-forward
115
- * Sunday became 03:30 there and stayed 03:30 on Monday, where 02:30 exists. `fromZoned` normalizes
116
- * the day overflow, so `day + offset` is the whole walk.
117
- */
118
- function localDay(origin: ZonedDateTime, offset: number, zone: TimeZone): Instant | null {
119
- const candidate = fromZoned(
120
- {
121
- year: origin.year,
122
- month: origin.month,
123
- day: origin.day + offset,
124
- hour: origin.hour,
125
- minute: origin.minute,
126
- second: origin.second,
127
- millisecond: origin.millisecond,
128
- },
129
- zone,
130
- { gap: 'next' },
131
- );
132
- const wanted = new Date(utcEpoch(origin.year, origin.month, origin.day + offset));
133
- const got = toZoned(candidate, zone);
134
- const exists =
135
- got.year === wanted.getUTCFullYear() &&
136
- got.month === wanted.getUTCMonth() + 1 &&
137
- got.day === wanted.getUTCDate();
138
- return exists ? candidate : null;
139
- }
140
-
141
146
  /** The next business day at the same local time; today if it already qualifies. */
142
147
  export function nextBusinessDay(at: Instant, calendar: BusinessCalendar): Instant {
143
148
  return isBusinessDay(at, calendar) ? at : addBusinessDays(at, 1, calendar);
@@ -167,11 +172,17 @@ export function businessDaysBetween(
167
172
  // of iterations. Each one is a calendar DATE counted from `start`'s — not the instant a day after
168
173
  // the previous one, which steps over a date the zone skipped and so reaches past `to`.
169
174
  const days = daysBetween(start, end, calendar.zone);
170
- const origin = toZoned(start, calendar.zone);
171
- let count = 0;
172
- for (let index = 0; index < days; index += 1) {
173
- const day = localDay(origin, index, calendar.zone);
174
- if (day !== null && isBusinessDay(day, calendar)) count += 1;
175
+ const zoned = toZoned(start, calendar.zone);
176
+ const first = epochDayOf(zoned);
177
+ const last = first + days;
178
+ const weekend = calendar.weekendDays ?? WEEKEND_SAT_SUN;
179
+ const isWorkday = workdayTable(weekend);
180
+ // Whole weeks multiplied, holidays subtracted by binary search, and only the dates the zone
181
+ // never had at this wall time asked of `Intl` — it was every date, two to three calls each.
182
+ const holidays = holidayDays(calendar.holidays).filter((day) => isWorkday[weekdayOfDay(day)]);
183
+ let count = workdaysIn(first, last, isWorkday) - countIn(holidays, first, last);
184
+ for (const day of skippedDays(calendar.zone, zoned, first, last)) {
185
+ if (isWorkday[weekdayOfDay(day)] && countIn(holidays, day, day + 1) === 0) count -= 1;
175
186
  }
176
187
  // Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
177
188
  // `-0`, which `Object.is`, a `Map` key and every `toBe` treat as a value distinct from `0`.
package/src/context.ts CHANGED
@@ -11,6 +11,12 @@ 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 a zone picker writes: the visitor's CHOICE, ranked above the header. Published so the
16
+ * picker and `@ultimat3/http`'s default reader name one constant — each spelled its own literal.
17
+ */
18
+ export const TIMEZONE_COOKIE = 'x_timezone';
19
+
14
20
  /**
15
21
  * Cookie `@ultimat3/render`'s zone script writes from the same `Intl` call, on `app/` documents: a
16
22
  * document navigation sends no header, so without it a new visitor's first pages render in UTC.
package/src/format.ts CHANGED
@@ -140,10 +140,11 @@ const CALENDAR_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
140
140
  export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
141
141
  const zone = zoneOf(options);
142
142
  const delta = differenceMs(options.now, at);
143
- const formatter = new Intl.RelativeTimeFormat(assertLocale(options.locale), {
144
- numeric: options.numeric ?? 'auto',
145
- style: options.style ?? 'long',
146
- });
143
+ const formatter = relativeFormatterFor(
144
+ options.locale,
145
+ options.numeric ?? 'auto',
146
+ options.style ?? 'long',
147
+ );
147
148
  const magnitude = Math.abs(delta);
148
149
  // `days === 0` a full day apart is 24 real hours inside one 25-hour local day: no midnight was
149
150
  // crossed, so it is hours — "today" would be the calendar's answer to a question about elapsed
@@ -158,6 +159,25 @@ export function formatRelative(at: Instant, options: FormatRelativeOptions): str
158
159
  return formatter.format(0, 'second');
159
160
  }
160
161
 
162
+ const relativeCache = new Map<string, Intl.RelativeTimeFormat>();
163
+
164
+ /**
165
+ * `formatterFor`'s rule for the relative formatter: one per canonical locale and options, through
166
+ * core's bounded cache. Built per call it was most of what a 10k-row activity feed cost to render.
167
+ */
168
+ function relativeFormatterFor(
169
+ locale: string,
170
+ numeric: 'always' | 'auto',
171
+ style: 'long' | 'short' | 'narrow',
172
+ ): Intl.RelativeTimeFormat {
173
+ const tag = assertLocale(locale);
174
+ return cachedFormatter(
175
+ relativeCache,
176
+ `${tag}|${numeric}|${style}`,
177
+ () => new Intl.RelativeTimeFormat(tag, { numeric, style }),
178
+ );
179
+ }
180
+
161
181
  /** `14–16 Mar 2026` — one call, so the locale decides how to collapse the range. */
162
182
  export function formatRange(from: Instant, to: Instant, options: FormatDateTimeOptions): string {
163
183
  const zone = zoneOf(options);
@@ -174,6 +194,9 @@ export function formatRange(from: Instant, to: Instant, options: FormatDateTimeO
174
194
  return `${formatter.format(from)} – ${formatter.format(to)}`;
175
195
  }
176
196
 
197
+ /** One English ordinal rule set for the process: it takes no locale, so it has no key. */
198
+ let ordinalRules: Intl.PluralRules | undefined;
199
+
177
200
  const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
178
201
  one: 'st',
179
202
  two: 'nd',
@@ -195,7 +218,8 @@ const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
195
218
  * rendered page. **Breaking: the `locale` parameter is gone.**
196
219
  */
197
220
  export function ordinal(value: number): string {
198
- const category = new Intl.PluralRules('en', { type: 'ordinal' }).select(value);
221
+ ordinalRules ??= new Intl.PluralRules('en', { type: 'ordinal' });
222
+ const category = ordinalRules.select(value);
199
223
  return `${value}${ORDINAL_SUFFIX[category]}`;
200
224
  }
201
225
 
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ export {
10
10
  type IsoWeekday,
11
11
  isBusinessDay,
12
12
  isHoliday,
13
- isWeekend,
14
13
  nextBusinessDay,
15
14
  WEEKEND_FRI_SAT,
16
15
  WEEKEND_SAT_SUN,
@@ -20,6 +19,7 @@ export {
20
19
  configureTime,
21
20
  currentTimeZone,
22
21
  resolveTimeZone,
22
+ TIMEZONE_COOKIE,
23
23
  TIMEZONE_DETECTED_COOKIE,
24
24
  TIMEZONE_HEADER,
25
25
  type TimeConfig,
@@ -32,7 +32,6 @@ export {
32
32
  type CronExpression,
33
33
  type CronPhrases,
34
34
  describeCron,
35
- firedSince,
36
35
  isValidCron,
37
36
  matchesCron,
38
37
  nextCronOccurrence,
@@ -52,20 +51,11 @@ export {
52
51
  SECOND,
53
52
  toMs,
54
53
  toSeconds,
55
- WEEK,
56
54
  } from './duration';
57
55
  export {
58
- cronInvalid,
59
- cronNotDescribable,
60
- dstAmbiguous,
61
- dstNonexistent,
62
- durationInvalid,
63
- instantInvalid,
64
56
  TIME_ERROR_CODES,
65
- TIME_ERROR_TITLES,
66
57
  TimeError,
67
58
  type TimeErrorCode,
68
- timezoneInvalid,
69
59
  } from './errors';
70
60
  export {
71
61
  type DateTimeStyle,
@@ -74,7 +64,6 @@ export {
74
64
  type FormatRelativeOptions,
75
65
  formatDate,
76
66
  formatDateTime,
77
- formatIsoDate,
78
67
  formatRange,
79
68
  formatRelative,
80
69
  formatTime,
@@ -83,22 +72,15 @@ export {
83
72
  } from './format';
84
73
  export {
85
74
  addMs,
86
- compareInstants,
87
75
  differenceMs,
88
76
  epoch,
89
77
  fromEpochMs,
90
- fromEpochSeconds,
91
78
  fromIso,
92
79
  type Instant,
93
80
  instant,
94
- isAfter,
95
- isBefore,
96
- isInstant,
97
81
  now,
98
- subtractMs,
99
82
  toEpochMs,
100
83
  toIso,
101
- toIsoDateUtc,
102
84
  } from './instant';
103
85
  export { addMonthsInZone, addPlainMonths } from './months';
104
86
  export {
@@ -124,9 +106,6 @@ export {
124
106
  export {
125
107
  type LocalSlot,
126
108
  nextLocalSlot,
127
- nextLocalSlots,
128
- nextWeeklySlot,
129
- type WeeklySlot,
130
109
  } from './schedule';
131
110
  /**
132
111
  * `canonicalTimeZone` is public because a zone arriving from a request header has to become one
@@ -157,7 +136,6 @@ export {
157
136
  isValidTimeZone,
158
137
  observesDst,
159
138
  offsetAt,
160
- offsetLabel,
161
139
  type TimeZone,
162
140
  UTC,
163
141
  type ZoneParts,
@@ -0,0 +1,113 @@
1
+ // Single responsibility: which local calendar dates a zone never had at a given wall time — Samoa's
2
+ // 2011-12-30, or a late-evening gap that pushes the clock past midnight — found by sampling the
3
+ // zone's offset, not by asking `Intl` about every day. Each candidate is still settled by `localDayAt`.
4
+
5
+ import type { Instant } from './instant';
6
+ import { fromZoned, toZoned } from './zoned';
7
+ import { offsetSecondsAt, type TimeZone } from './zones';
8
+
9
+ const DAY_MS = 86_400_000;
10
+ const HOUR_MS = 3_600_000;
11
+
12
+ /** A wall time of day: the part of the origin every candidate date keeps. */
13
+ export interface WallTime {
14
+ readonly hour: number;
15
+ readonly minute: number;
16
+ readonly second: number;
17
+ readonly millisecond: number;
18
+ }
19
+
20
+ /**
21
+ * The instant `day` (an epoch day) names at `wall` in `zone`, or `null` when the zone never had
22
+ * that date at that time. Built from the calendar date and the ORIGINAL wall time, never from the
23
+ * day before it: chaining a day at a time carried a DST repair (02:30 → 03:30) onto every later day.
24
+ * `{ gap: 'next' }` moves a skipped wall time forward; when that lands on another date, this date
25
+ * did not happen at this time.
26
+ */
27
+ export function localDayAt(day: number, wall: WallTime, zone: TimeZone): Instant | null {
28
+ const date = new Date(day * DAY_MS);
29
+ const year = date.getUTCFullYear();
30
+ const month = date.getUTCMonth() + 1;
31
+ const dayOfMonth = date.getUTCDate();
32
+ const { hour, minute, second, millisecond } = wall;
33
+ const candidate = fromZoned(
34
+ { year, month, day: dayOfMonth, hour, minute, second, millisecond },
35
+ zone,
36
+ { gap: 'next' },
37
+ );
38
+ const got = toZoned(candidate, zone);
39
+ return got.year === year && got.month === month && got.day === dayOfMonth ? candidate : null;
40
+ }
41
+
42
+ /**
43
+ * The sampling stride. A transition is found as a change of offset between two samples, so two
44
+ * transitions inside one stride that cancel are invisible. Measured over every zone `Intl` lists,
45
+ * 1900–2050 (2026-11): the closest such pairs are 7.0 days apart (Brazil 2000, Gaza 2040), and
46
+ * every pair under 28 days is a ONE-hour shift. A 1-hour gap moves a date only for a wall time from
47
+ * 23:00 (see `skippedDays`), so 28 days is exact below 23:00 and 6 days above it.
48
+ */
49
+ const STRIDE_DAYS = 28;
50
+ const LATE_STRIDE_DAYS = 6;
51
+ const LATE_WALL_MS = 23 * HOUR_MS;
52
+
53
+ const offsetMs = (zone: TimeZone, epochMs: number): number =>
54
+ offsetSecondsAt(zone, new Date(epochMs) as Instant) * 1000;
55
+
56
+ /**
57
+ * The epoch days in `[from, to)` that `localDayAt` answers `null` for, ascending.
58
+ *
59
+ * A gap of `Δ` makes `{ gap: 'next' }` land `Δ` later on the wall clock, so it changes the DATE only
60
+ * when `wall + Δ` reaches midnight: a 1-hour spring-forward never removes a 09:00, and Samoa's
61
+ * 24-hour jump removes every time of day. Only a transition that can do that is located (to the
62
+ * day) and its neighbouring dates checked exactly; everything else is a date that exists.
63
+ */
64
+ export function skippedDays(zone: TimeZone, wall: WallTime, from: number, to: number): number[] {
65
+ if (to <= from) return [];
66
+ const wallMs = wall.hour * HOUR_MS + wall.minute * 60_000 + wall.second * 1000 + wall.millisecond;
67
+ const stride = (wallMs >= LATE_WALL_MS ? LATE_STRIDE_DAYS : STRIDE_DAYS) * DAY_MS;
68
+ // Two days of margin each side: a historic offset reaches ±15:56 (Manila's LMT).
69
+ const start = from * DAY_MS - 2 * DAY_MS;
70
+ const end = to * DAY_MS + 2 * DAY_MS;
71
+ const found = new Set<number>();
72
+ let left = start;
73
+ let leftOffset = offsetMs(zone, left);
74
+ while (left < end) {
75
+ const right = Math.min(left + stride, end);
76
+ const rightOffset = offsetMs(zone, right);
77
+ if (rightOffset !== leftOffset) {
78
+ locate(zone, left, leftOffset, right, rightOffset, (low, lowOffset, high, highOffset) => {
79
+ const jump = highOffset - lowOffset;
80
+ if (jump <= 0 || wallMs + jump < DAY_MS - 1000) return;
81
+ const first = Math.floor((low + lowOffset - wallMs) / DAY_MS) - 1;
82
+ const last = Math.floor((high + highOffset - wallMs) / DAY_MS) + 1;
83
+ for (let day = Math.max(first, from); day <= Math.min(last, to - 1); day += 1) {
84
+ if (localDayAt(day, wall, zone) === null) found.add(day);
85
+ }
86
+ });
87
+ }
88
+ left = right;
89
+ leftOffset = rightOffset;
90
+ }
91
+ return [...found].sort((a, b) => a - b);
92
+ }
93
+
94
+ type Leaf = (low: number, lowOffset: number, high: number, highOffset: number) => void;
95
+
96
+ /** Halve `[low, high]` down to a day around each change of offset; `visit` each such day. */
97
+ function locate(
98
+ zone: TimeZone,
99
+ low: number,
100
+ lowOffset: number,
101
+ high: number,
102
+ highOffset: number,
103
+ visit: Leaf,
104
+ ): void {
105
+ if (high - low <= DAY_MS) {
106
+ visit(low, lowOffset, high, highOffset);
107
+ return;
108
+ }
109
+ const mid = low + Math.floor((high - low) / 2);
110
+ const midOffset = offsetMs(zone, mid);
111
+ if (midOffset !== lowOffset) locate(zone, low, lowOffset, mid, midOffset, visit);
112
+ if (highOffset !== midOffset) locate(zone, mid, midOffset, high, highOffset, visit);
113
+ }