@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 +15 -2
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/business-span.ts +115 -0
- package/src/business.ts +66 -55
- package/src/context.ts +6 -0
- package/src/format.ts +29 -5
- package/src/index.ts +1 -23
- package/src/zone-skipped-days.ts +113 -0
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`
|
|
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": "
|
|
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": "
|
|
50
|
-
"@ultimat3/schema": "
|
|
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 {
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 =
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
+
}
|