@ultimat3/time 23.0.0 → 25.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 +42 -1
- package/README.md +74 -4
- package/package.json +4 -4
- package/src/business.ts +83 -15
- package/src/context.ts +5 -4
- package/src/cron-occurrence.ts +90 -8
- package/src/cron-parse.ts +24 -10
- package/src/duration.ts +6 -2
- package/src/errors.ts +29 -5
- package/src/format.ts +53 -17
- package/src/holidays.ts +32 -0
- package/src/index.ts +9 -4
- package/src/months.ts +118 -0
- package/src/plain-date-range.ts +75 -0
- package/src/plain-date.ts +30 -7
- package/src/zone-canonical.ts +5 -0
- package/src/zoned.ts +29 -2
- package/src/zones.ts +10 -1
package/src/duration.ts
CHANGED
|
@@ -152,7 +152,9 @@ export function formatDuration(
|
|
|
152
152
|
// Screened once, here, rather than at each of the three `Intl` constructions below — and before
|
|
153
153
|
// any of them, so a malformed tag is one refusal and never a partially built string.
|
|
154
154
|
const tag = assertLocale(locale);
|
|
155
|
-
|
|
155
|
+
// `NaN` rendered as "NaN days, NaN hr" — every `Math.floor` below propagates it and `Intl`
|
|
156
|
+
// formats it. `finiteOption`, as `toMs`: a duration is legitimately negative and fractional.
|
|
157
|
+
let remaining = Math.abs(Math.round(finiteOption('formatDuration', 'ms', ms)));
|
|
156
158
|
const pieces: string[] = [];
|
|
157
159
|
|
|
158
160
|
for (const [unit, scale] of FORMAT_UNITS) {
|
|
@@ -179,7 +181,9 @@ export function formatDuration(
|
|
|
179
181
|
|
|
180
182
|
/** `PT2H30M` — for OpenAPI schemas and cron metadata. */
|
|
181
183
|
export function formatDurationIso(ms: number): string {
|
|
182
|
-
|
|
184
|
+
// Screened for the reason `formatDuration` is: `NaN` fails every `> 0` below and came out as
|
|
185
|
+
// `P0D`, a well-formed zero for a duration nobody computed.
|
|
186
|
+
const total = Math.abs(Math.round(finiteOption('formatDurationIso', 'ms', ms)));
|
|
183
187
|
const days = Math.floor(total / DAY);
|
|
184
188
|
const hours = Math.floor((total % DAY) / HOUR);
|
|
185
189
|
const minutes = Math.floor((total % HOUR) / MINUTE);
|
package/src/errors.ts
CHANGED
|
@@ -53,13 +53,35 @@ export class TimeError extends UltimateError {
|
|
|
53
53
|
/**
|
|
54
54
|
* A wall-clock field outside its range. Separate from `X_DST_*`, which are about times that
|
|
55
55
|
* are legitimately absent or doubled — this one is a spec the caller got wrong.
|
|
56
|
+
*
|
|
57
|
+
* A business calendar with no business day in it (`business.ts`) is the one refusal that is not
|
|
58
|
+
* about a number, and it has two causes with two different repairs, so the caller names which
|
|
59
|
+
* through `field`: `calendar.weekendDays` (the weekend covers the week — shorten it) or
|
|
60
|
+
* `calendar.holidays` (every remaining day is a holiday — remove some). A branch here rather than a
|
|
61
|
+
* second factory, so the code keeps one constructor. Each fix is a call that runs as written; the
|
|
62
|
+
* explanation is the cause's job.
|
|
56
63
|
*/
|
|
57
64
|
export function scheduleInvalid(field: string, value: unknown, range: string): TimeError {
|
|
65
|
+
// `value` is whatever a caller put in a `LocalSlot` — this factory is exported, so it is a form
|
|
66
|
+
// field or a config value as often as it is the `number` the in-package caller passes.
|
|
67
|
+
const cause = `${field} must be ${range}, got ${renderCauseValue(value)}`;
|
|
68
|
+
if (field === 'calendar.weekendDays') {
|
|
69
|
+
return new TimeError({
|
|
70
|
+
code: 'X_SCHEDULE_INVALID',
|
|
71
|
+
cause,
|
|
72
|
+
fix: "addBusinessDays(new Date(), 1, { zone: 'UTC', weekendDays: [6, 7] })",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (field === 'calendar.holidays') {
|
|
76
|
+
return new TimeError({
|
|
77
|
+
code: 'X_SCHEDULE_INVALID',
|
|
78
|
+
cause,
|
|
79
|
+
fix: "addBusinessDays(new Date(), 1, { zone: 'UTC', holidays: [] })",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
58
82
|
return new TimeError({
|
|
59
83
|
code: 'X_SCHEDULE_INVALID',
|
|
60
|
-
|
|
61
|
-
// field or a config value as often as it is the `number` the in-package caller passes.
|
|
62
|
-
cause: `${field} must be ${range}, got ${renderCauseValue(value)}`,
|
|
84
|
+
cause,
|
|
63
85
|
fix: `pass an integer in ${range} for ${field} — wall-clock fields are not wrapped or clamped, because a silently shifted schedule is worse than a failed one`,
|
|
64
86
|
});
|
|
65
87
|
}
|
|
@@ -69,10 +91,12 @@ export function scheduleInvalid(field: string, value: unknown, range: string): T
|
|
|
69
91
|
* abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label
|
|
70
92
|
* link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction.
|
|
71
93
|
*/
|
|
72
|
-
export function timezoneInvalid(zone:
|
|
94
|
+
export function timezoneInvalid(zone: unknown): TimeError {
|
|
73
95
|
return new TimeError({
|
|
74
96
|
code: 'X_TIMEZONE_INVALID',
|
|
75
|
-
|
|
97
|
+
// `unknown`, because the refusal is reached by exactly the callers the `string` type did not
|
|
98
|
+
// stop — an omitted `zone` is `undefined` here. A string keeps its quoted spelling.
|
|
99
|
+
cause: `${typeof zone === 'string' ? `"${zone}"` : renderCauseValue(zone)} is not an IANA Area/Location zone name`,
|
|
76
100
|
fix: "use Area/Location, or UTC. A single-label legacy name swaps mechanically — Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC. An abbreviation or a numeric offset does not: CET and EST5EDT name no jurisdiction and carry no DST rule, so name the city whose clock you mean (Europe/Paris, America/New_York). Every accepted name: Intl.supportedValuesOf('timeZone')",
|
|
77
101
|
});
|
|
78
102
|
}
|
package/src/format.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { assertLocale, cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
import { differenceMs, type Instant } from './instant';
|
|
9
|
-
import { isoDateInZone } from './zoned';
|
|
9
|
+
import { daysBetween, isoDateInZone } from './zoned';
|
|
10
10
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
11
11
|
|
|
12
12
|
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
|
@@ -27,9 +27,10 @@ export interface FormatDateTimeOptions extends FormatContext {
|
|
|
27
27
|
|
|
28
28
|
/** `14 Mar 2026, 09:00` in `en-GB` / `Europe/Berlin`. */
|
|
29
29
|
export function formatDateTime(at: Instant, options: FormatDateTimeOptions): string {
|
|
30
|
+
const zone = zoneOf(options);
|
|
30
31
|
const style = options.style ?? 'medium';
|
|
31
32
|
return formatterFor(options.locale, {
|
|
32
|
-
timeZone:
|
|
33
|
+
timeZone: zone,
|
|
33
34
|
dateStyle: options.dateStyle ?? style,
|
|
34
35
|
timeStyle: options.timeStyle ?? (style === 'full' || style === 'long' ? 'medium' : style),
|
|
35
36
|
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
@@ -40,8 +41,9 @@ export function formatDate(
|
|
|
40
41
|
at: Instant,
|
|
41
42
|
options: FormatContext & { style?: DateTimeStyle },
|
|
42
43
|
): string {
|
|
44
|
+
const zone = zoneOf(options);
|
|
43
45
|
return formatterFor(options.locale, {
|
|
44
|
-
timeZone:
|
|
46
|
+
timeZone: zone,
|
|
45
47
|
dateStyle: options.style ?? 'medium',
|
|
46
48
|
}).format(at);
|
|
47
49
|
}
|
|
@@ -50,8 +52,9 @@ export function formatTime(
|
|
|
50
52
|
at: Instant,
|
|
51
53
|
options: FormatContext & { style?: DateTimeStyle; hour12?: boolean },
|
|
52
54
|
): string {
|
|
55
|
+
const zone = zoneOf(options);
|
|
53
56
|
return formatterFor(options.locale, {
|
|
54
|
-
timeZone:
|
|
57
|
+
timeZone: zone,
|
|
55
58
|
timeStyle: options.style ?? 'short',
|
|
56
59
|
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
57
60
|
}).format(at);
|
|
@@ -63,11 +66,12 @@ export function formatTime(
|
|
|
63
66
|
* in a fixed position instead of wherever the locale pattern happens to put it.
|
|
64
67
|
*/
|
|
65
68
|
export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): string {
|
|
69
|
+
const zone = zoneOf(options);
|
|
66
70
|
const style = options.style ?? 'medium';
|
|
67
71
|
// `timeZoneName` is a component option, and Intl forbids mixing those with dateStyle /
|
|
68
72
|
// timeStyle — so the components are spelled out here instead.
|
|
69
73
|
const parts = formatterFor(options.locale, {
|
|
70
|
-
timeZone:
|
|
74
|
+
timeZone: zone,
|
|
71
75
|
year: 'numeric',
|
|
72
76
|
month: style === 'short' ? 'numeric' : style === 'medium' ? 'short' : 'long',
|
|
73
77
|
day: 'numeric',
|
|
@@ -100,44 +104,66 @@ export function formatIsoDate(at: Instant, zone: TimeZone): string {
|
|
|
100
104
|
return isoDateInZone(at, assertTimeZone(zone));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export interface FormatRelativeOptions extends
|
|
107
|
+
export interface FormatRelativeOptions extends FormatContext {
|
|
104
108
|
/** The reference point. Pass `now(clock)` — never let this default to a live clock. */
|
|
105
109
|
now: Instant;
|
|
106
110
|
numeric?: 'always' | 'auto';
|
|
107
111
|
style?: 'long' | 'short' | 'narrow';
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
['day', 86_400_000],
|
|
114
|
+
const DAY_MS = 86_400_000;
|
|
115
|
+
|
|
116
|
+
/** Under a day apart: elapsed time, largest unit first. */
|
|
117
|
+
const CLOCK_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
115
118
|
['hour', 3_600_000],
|
|
116
119
|
['minute', 60_000],
|
|
117
120
|
['second', 1000],
|
|
118
121
|
];
|
|
119
122
|
|
|
120
|
-
/**
|
|
123
|
+
/** A day or more apart: calendar days in the zone, largest unit first. */
|
|
124
|
+
const CALENDAR_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
125
|
+
['year', 365],
|
|
126
|
+
['month', 30],
|
|
127
|
+
['week', 7],
|
|
128
|
+
['day', 1],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `in 3 days` / `2 hours ago`, picking the largest unit that fits.
|
|
133
|
+
*
|
|
134
|
+
* From a day apart, the number is CALENDAR days in `zone` — local midnights crossed, the count
|
|
135
|
+
* `daysBetween` gives — never elapsed milliseconds truncated. "Tomorrow" is a statement about the
|
|
136
|
+
* calendar: 47 hours ahead is two midnights away, and `trunc(47 / 24)` called it tomorrow. That is
|
|
137
|
+
* why this takes a zone like every other formatter here; which day an instant falls on has no
|
|
138
|
+
* answer without one.
|
|
139
|
+
*/
|
|
121
140
|
export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
|
|
141
|
+
const zone = zoneOf(options);
|
|
122
142
|
const delta = differenceMs(options.now, at);
|
|
123
143
|
const formatter = new Intl.RelativeTimeFormat(assertLocale(options.locale), {
|
|
124
144
|
numeric: options.numeric ?? 'auto',
|
|
125
145
|
style: options.style ?? 'long',
|
|
126
146
|
});
|
|
127
147
|
const magnitude = Math.abs(delta);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// `days === 0` a full day apart is 24 real hours inside one 25-hour local day: no midnight was
|
|
149
|
+
// crossed, so it is hours — "today" would be the calendar's answer to a question about elapsed
|
|
150
|
+
// time.
|
|
151
|
+
const days = magnitude >= DAY_MS ? daysBetween(options.now, at, zone) : 0;
|
|
152
|
+
for (const [unit, size] of CALENDAR_UNITS) {
|
|
153
|
+
if (Math.abs(days) >= size) return formatter.format(Math.trunc(days / size), unit);
|
|
154
|
+
}
|
|
155
|
+
for (const [unit, ms] of CLOCK_UNITS) {
|
|
156
|
+
if (magnitude >= ms) return formatter.format(Math.trunc(delta / ms), unit);
|
|
132
157
|
}
|
|
133
158
|
return formatter.format(0, 'second');
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
/** `14–16 Mar 2026` — one call, so the locale decides how to collapse the range. */
|
|
137
162
|
export function formatRange(from: Instant, to: Instant, options: FormatDateTimeOptions): string {
|
|
163
|
+
const zone = zoneOf(options);
|
|
138
164
|
const style = options.style ?? 'medium';
|
|
139
165
|
const formatter = formatterFor(options.locale, {
|
|
140
|
-
timeZone:
|
|
166
|
+
timeZone: zone,
|
|
141
167
|
dateStyle: options.dateStyle ?? style,
|
|
142
168
|
...(options.timeStyle === undefined ? {} : { timeStyle: options.timeStyle }),
|
|
143
169
|
}) as Intl.DateTimeFormat & {
|
|
@@ -173,6 +199,16 @@ export function ordinal(value: number): string {
|
|
|
173
199
|
return `${value}${ORDINAL_SUFFIX[category]}`;
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The options' zone, canonical — read FIRST in every formatter, and through `?.`: a call from
|
|
204
|
+
* untyped code with the options object missing is the same mistake as one with `zone` missing, and
|
|
205
|
+
* both are `X_TIMEZONE_INVALID` rather than a bare `TypeError` on the first property read.
|
|
206
|
+
*/
|
|
207
|
+
function zoneOf(options: { readonly zone: TimeZone }): TimeZone {
|
|
208
|
+
const loose: { readonly zone?: unknown } | null | undefined = options;
|
|
209
|
+
return assertTimeZone(loose?.zone as TimeZone);
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
const cache = new Map<string, Intl.DateTimeFormat>();
|
|
177
213
|
|
|
178
214
|
/**
|
package/src/holidays.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The holiday lookup behind `isHoliday`: one `Set` per holiday LIST, built on first use. A business
|
|
3
|
+
* calendar is asked about every day it walks, and `includes` on a long closure list made each walk
|
|
4
|
+
* quadratic in the list.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keyed on the list itself, in a `WeakMap`: the cache is bounded by the lists still alive, with no
|
|
9
|
+
* cap to tune and no entry outliving its calendar, and a calendar whose `holidays` is REPLACED
|
|
10
|
+
* misses at once.
|
|
11
|
+
*
|
|
12
|
+
* The list is FROZEN when its set is built, so the two can never disagree. `readonly` is only a
|
|
13
|
+
* type, and an in-place edit (a push, or an element overwritten at the same length) would otherwise
|
|
14
|
+
* be answered from the stale set. Detecting one costs a pass over the list per lookup — the cost
|
|
15
|
+
* the set exists to remove — so the edit is made impossible instead: in strict code (every ES
|
|
16
|
+
* module) it throws a `TypeError` at the line that tried it. Freezing an already-frozen list, or one
|
|
17
|
+
* `plainDateRange` returned, changes nothing.
|
|
18
|
+
*/
|
|
19
|
+
const memo = new WeakMap<readonly string[], ReadonlySet<string>>();
|
|
20
|
+
|
|
21
|
+
const EMPTY: ReadonlySet<string> = new Set<string>();
|
|
22
|
+
|
|
23
|
+
/** The holiday dates of a calendar, as a set — the same set for the same list, always. */
|
|
24
|
+
export function holidaySet(holidays: readonly string[] | undefined): ReadonlySet<string> {
|
|
25
|
+
if (holidays === undefined) return EMPTY;
|
|
26
|
+
const hit = memo.get(holidays);
|
|
27
|
+
if (hit !== undefined) return hit;
|
|
28
|
+
Object.freeze(holidays);
|
|
29
|
+
const set = new Set(holidays);
|
|
30
|
+
memo.set(holidays, set);
|
|
31
|
+
return set;
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/** Public surface of @ultimat3/time. Explicit exports only. */
|
|
2
2
|
|
|
3
3
|
// `localeInvalid` and the `X_LOCALE_INVALID` it carries are `@ultimat3/core`'s since 16.x —
|
|
4
|
-
// `@ultimat3/money` needs the same screen and `money -> time` is a sideways import.
|
|
5
|
-
//
|
|
6
|
-
// `@ultimat3/action` and `@ultimat3/query` re-export core's client-flight names.
|
|
7
|
-
export { localeInvalid } from '@ultimat3/core';
|
|
4
|
+
// `@ultimat3/money` needs the same screen and `money -> time` is a sideways import. Imported from
|
|
5
|
+
// core by every caller; 25.0.0 dropped this package's re-export (`X_HELPER_COPY`).
|
|
8
6
|
export {
|
|
9
7
|
addBusinessDays,
|
|
10
8
|
type BusinessCalendar,
|
|
@@ -101,6 +99,7 @@ export {
|
|
|
101
99
|
toIso,
|
|
102
100
|
toIsoDateUtc,
|
|
103
101
|
} from './instant';
|
|
102
|
+
export { addMonthsInZone, addPlainMonths } from './months';
|
|
104
103
|
export {
|
|
105
104
|
addPlainDays,
|
|
106
105
|
comparePlainDates,
|
|
@@ -116,6 +115,11 @@ export {
|
|
|
116
115
|
plainDateUtc,
|
|
117
116
|
plainDaysBetween,
|
|
118
117
|
} from './plain-date';
|
|
118
|
+
export {
|
|
119
|
+
MAX_PLAIN_DATE_RANGE_DAYS,
|
|
120
|
+
type PlainDateRangeOptions,
|
|
121
|
+
plainDateRange,
|
|
122
|
+
} from './plain-date-range';
|
|
119
123
|
export {
|
|
120
124
|
type LocalSlot,
|
|
121
125
|
nextLocalSlot,
|
|
@@ -138,6 +142,7 @@ export {
|
|
|
138
142
|
fromZonedDetailed,
|
|
139
143
|
type GapPolicy,
|
|
140
144
|
isoDateInZone,
|
|
145
|
+
isoInZone,
|
|
141
146
|
isSameLocalDay,
|
|
142
147
|
type OverlapPolicy,
|
|
143
148
|
startOfDay,
|
package/src/months.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Month arithmetic: the one calendar step with no fixed length. Both functions clamp the day to the
|
|
3
|
+
* target month's last day, and the zoned one does it on the LOCAL date — never UTC month math.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { TimeError } from './errors';
|
|
8
|
+
import { type Instant, instant } from './instant';
|
|
9
|
+
import { daysInMonth, type PlainDate, plainDateOf, plainDateParts } from './plain-date';
|
|
10
|
+
import { fromZoned, toZoned } from './zoned';
|
|
11
|
+
import { assertTimeZone, type TimeZone } from './zones';
|
|
12
|
+
|
|
13
|
+
/** The last month a `PlainDate` can name, counted from year 0's January: 9999-12. */
|
|
14
|
+
const LAST_MONTH = 9999 * 12 + 11;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `date` moved by `months` calendar months, the day clamped to the target month's last day:
|
|
18
|
+
* `2026-01-31 + 1` is `2026-02-28`, `2024-01-31 + 1` is `2024-02-29`, `2026-03-31 − 1` is
|
|
19
|
+
* `2026-02-28`. Negative counts go back; the year rolls either way.
|
|
20
|
+
*
|
|
21
|
+
* A clamp does not compose: `2026-01-31 + 1 + 1` is `2026-03-28`, while `2026-01-31 + 2` is
|
|
22
|
+
* `2026-03-31`. A recurring date (a renewal, a billing day) is computed from its ANCHOR —
|
|
23
|
+
* `addPlainMonths(anchor, n)` for the n-th — never chained from the previous result.
|
|
24
|
+
*
|
|
25
|
+
* A whole number of months, checked as `addPlainDays` checks its days; a result outside years
|
|
26
|
+
* 0000-9999 is refused rather than branded.
|
|
27
|
+
*/
|
|
28
|
+
export function addPlainMonths(date: PlainDate, months: number): PlainDate {
|
|
29
|
+
// Parsed first, so every refusal below can paste the caller's date back as a checked literal.
|
|
30
|
+
const { year, month, day } = plainDateParts(date);
|
|
31
|
+
if (!Number.isSafeInteger(months)) {
|
|
32
|
+
throw monthsInvalid(
|
|
33
|
+
`months must be a whole number of months, got ${renderCauseValue(months)}`,
|
|
34
|
+
`addPlainMonths(${dateLiteral(date)}, ${wholeMonths(months)}) # months is a whole number — round it before the call`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// Months counted from year 0 so one floor-division carries the year in both directions.
|
|
38
|
+
const from = year * 12 + (month - 1);
|
|
39
|
+
const total = from + months;
|
|
40
|
+
if (total < 0 || total > LAST_MONTH) {
|
|
41
|
+
// The furthest count that still lands in 0000-9999, in the direction the caller asked.
|
|
42
|
+
const furthest = total < 0 ? -from : LAST_MONTH - from;
|
|
43
|
+
throw monthsInvalid(
|
|
44
|
+
`${date} ${months < 0 ? '-' : '+'} ${Math.abs(months)} months leaves years 0000-9999`,
|
|
45
|
+
`addPlainMonths(${dateLiteral(date)}, ${furthest}) # the furthest a PlainDate reaches from ${date}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const targetYear = Math.floor(total / 12);
|
|
49
|
+
const targetMonth = total - targetYear * 12 + 1;
|
|
50
|
+
return plainDateOf({
|
|
51
|
+
year: targetYear,
|
|
52
|
+
month: targetMonth,
|
|
53
|
+
day: Math.min(day, daysInMonth(targetYear, targetMonth)),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `at` moved by `months` calendar months in `zone`, keeping the LOCAL wall-clock time: 09:00 EST
|
|
59
|
+
* on Feb 15 + 1 is 09:00 EDT on Mar 15 — 743 hours later, not 744. The local DATE moves by
|
|
60
|
+
* `addPlainMonths` (so it clamps exactly as a plain date does), then the original wall time is
|
|
61
|
+
* resolved on it once. The zone is required: an instant has a month only in a zone.
|
|
62
|
+
*
|
|
63
|
+
* The target wall time can fail to exist, or exist twice. The rule is Temporal's `'compatible'`
|
|
64
|
+
* disambiguation, and it is not configurable:
|
|
65
|
+
*
|
|
66
|
+
* | Target wall time | Resolves to |
|
|
67
|
+
* |---|---|
|
|
68
|
+
* | in a spring-forward GAP | moved FORWARD by the gap's length — 02:30 New York → 03:30 EDT; 02:15 Lord Howe (a 30-minute shift) → 02:45 |
|
|
69
|
+
* | in a fall-back OVERLAP | the EARLIER instant, i.e. the pre-transition offset — 01:30 New York → 01:30 EDT |
|
|
70
|
+
*
|
|
71
|
+
* Those are `fromZoned`'s `{ gap: 'next', overlap: 'first' }`, the policies `addDaysInZone` uses.
|
|
72
|
+
* A caller needing another answer reads the local date from this result and calls `fromZoned` with
|
|
73
|
+
* its own policy. `months === 0` returns a copy of `at` untouched, even inside an overlap's second
|
|
74
|
+
* pass — re-resolving it would answer the first.
|
|
75
|
+
*/
|
|
76
|
+
export function addMonthsInZone(at: Instant, months: number, timeZone: TimeZone): Instant {
|
|
77
|
+
// The zone first: an omitted one is the mistake this signature exists to make loud.
|
|
78
|
+
const zone = assertTimeZone(timeZone);
|
|
79
|
+
if (!Number.isSafeInteger(months)) {
|
|
80
|
+
throw monthsInvalid(
|
|
81
|
+
`months must be a whole number of months, got ${renderCauseValue(months)}`,
|
|
82
|
+
`addMonthsInZone(at, ${wholeMonths(months)}, ${renderFixLiteral(zone, '<Area/Location>')}) # months is a whole number — round it before the call`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (months === 0) return instant(at);
|
|
86
|
+
const local = toZoned(at, zone);
|
|
87
|
+
const target = plainDateParts(
|
|
88
|
+
addPlainMonths(plainDateOf({ year: local.year, month: local.month, day: local.day }), months),
|
|
89
|
+
);
|
|
90
|
+
return fromZoned(
|
|
91
|
+
{
|
|
92
|
+
...target,
|
|
93
|
+
hour: local.hour,
|
|
94
|
+
minute: local.minute,
|
|
95
|
+
second: local.second,
|
|
96
|
+
millisecond: local.millisecond,
|
|
97
|
+
},
|
|
98
|
+
zone,
|
|
99
|
+
{ gap: 'next', overlap: 'first' },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A parsed `PlainDate` — digits and dashes only — as source that pastes as written. */
|
|
104
|
+
const dateLiteral = (date: PlainDate): string =>
|
|
105
|
+
`plainDate(${renderFixLiteral(date, '<YYYY-MM-DD>')})`;
|
|
106
|
+
|
|
107
|
+
/** The whole count nearest what the caller meant, or `1` when there is nothing to round (NaN). */
|
|
108
|
+
const wholeMonths = (months: number): number => {
|
|
109
|
+
const rounded = Math.trunc(months);
|
|
110
|
+
return Number.isSafeInteger(rounded) ? rounded : 1;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `X_SCHEDULE_INVALID` with the corrected CALL as its fix — `scheduleInvalid`'s generic line names a
|
|
115
|
+
* field to repair, which is prose here. `TimeError` takes its fix per throw, so the code is shared.
|
|
116
|
+
*/
|
|
117
|
+
const monthsInvalid = (cause: string, fix: string): TimeError =>
|
|
118
|
+
new TimeError({ code: 'X_SCHEDULE_INVALID', cause, fix });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plainDateRange` — the calendar dates between two `PlainDate`s, as a bounded, frozen array. It is
|
|
3
|
+
* how a closure becomes holidays: `holidays: [...fixed, ...plainDateRange(from, to)]`, one list.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { TimeError } from './errors';
|
|
8
|
+
import { addPlainDays, type PlainDate, plainDate, plainDaysBetween } from './plain-date';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The widest span accepted, in days: ten years and change. A range is materialised, so an absurd
|
|
12
|
+
* one (`0000-01-01 … 9999-12-31`, 3.65 M dates) is refused before it allocates — a holiday list or
|
|
13
|
+
* a report window longer than this is a bug in whatever computed its ends.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_PLAIN_DATE_RANGE_DAYS = 3660;
|
|
16
|
+
|
|
17
|
+
export interface PlainDateRangeOptions {
|
|
18
|
+
/** Leave `end` out — `[start, end)`. Default `false`: `[start, end]`, the closure reading. */
|
|
19
|
+
readonly exclusive?: boolean;
|
|
20
|
+
/** Days between consecutive dates, a positive whole number. Default `1`. */
|
|
21
|
+
readonly stepDays?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every `stepDays`-th date from `start`, up to `end` — **inclusive by default** (`[start, end]`),
|
|
26
|
+
* `[start, end)` with `{ exclusive: true }`. `end` appears only when the step lands on it.
|
|
27
|
+
*
|
|
28
|
+
* An array, not a generator: every caller spreads it into a holiday list or iterates it twice, and
|
|
29
|
+
* the bound makes it small. Frozen: each call's list is its own, not a buffer to edit. A reversed
|
|
30
|
+
* range is refused rather than answered empty — `start > end` is a swapped argument far more often
|
|
31
|
+
* than a deliberate empty set — as is a span over `MAX_PLAIN_DATE_RANGE_DAYS`, whatever the step.
|
|
32
|
+
*/
|
|
33
|
+
export function plainDateRange(
|
|
34
|
+
start: PlainDate,
|
|
35
|
+
end: PlainDate,
|
|
36
|
+
options: PlainDateRangeOptions = {},
|
|
37
|
+
): readonly PlainDate[] {
|
|
38
|
+
const from = plainDate(start);
|
|
39
|
+
const to = plainDate(end);
|
|
40
|
+
const step = options.stepDays ?? 1;
|
|
41
|
+
if (!Number.isSafeInteger(step) || step < 1) {
|
|
42
|
+
throw rangeInvalid(
|
|
43
|
+
`stepDays must be a whole number of days, at least 1, got ${renderCauseValue(step)}`,
|
|
44
|
+
`plainDateRange(${literal(from)}, ${literal(to)}, { stepDays: 1 }) # stepDays is a whole number of days, at least 1`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const span = plainDaysBetween(from, to);
|
|
48
|
+
if (span < 0) {
|
|
49
|
+
throw rangeInvalid(
|
|
50
|
+
`end (${to}) precedes start (${from}) by ${-span} days`,
|
|
51
|
+
`plainDateRange(${literal(to)}, ${literal(from)}) # start first: end must not precede start`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (span > MAX_PLAIN_DATE_RANGE_DAYS) {
|
|
55
|
+
throw rangeInvalid(
|
|
56
|
+
`${from} … ${to} spans ${span} days, more than MAX_PLAIN_DATE_RANGE_DAYS (${MAX_PLAIN_DATE_RANGE_DAYS})`,
|
|
57
|
+
`plainDateRange(${literal(from)}, addPlainDays(${literal(from)}, ${MAX_PLAIN_DATE_RANGE_DAYS})) # at most ${MAX_PLAIN_DATE_RANGE_DAYS} days per range — split a longer span into several`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const last = options.exclusive === true ? span - 1 : span;
|
|
61
|
+
const dates: PlainDate[] = [];
|
|
62
|
+
for (let offset = 0; offset <= last; offset += step) dates.push(addPlainDays(from, offset));
|
|
63
|
+
return Object.freeze(dates);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A checked `PlainDate`'s literal — digits and dashes only, so it pastes into source as written. */
|
|
67
|
+
const literal = (date: PlainDate): string => `plainDate(${renderFixLiteral(date, '<YYYY-MM-DD>')})`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `X_SCHEDULE_INVALID`, with a fix that is the corrected CALL. `scheduleInvalid`'s generic fix
|
|
71
|
+
* names an integer field to repair, which reads as prose here and names nothing this function
|
|
72
|
+
* takes; `TimeError` takes its fix per throw, so the code stays the package's one range refusal.
|
|
73
|
+
*/
|
|
74
|
+
const rangeInvalid = (cause: string, fix: string): TimeError =>
|
|
75
|
+
new TimeError({ code: 'X_SCHEDULE_INVALID', cause, fix });
|
package/src/plain-date.ts
CHANGED
|
@@ -37,8 +37,11 @@ export interface PlainDateParts {
|
|
|
37
37
|
|
|
38
38
|
const pad = (value: number, width: number): string => String(value).padStart(width, '0');
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Days in a month, Gregorian. February is the only interesting one. Exported for `months.ts`'s
|
|
42
|
+
* clamp — one leap-year rule in the package — and deliberately not re-exported by `index.ts`.
|
|
43
|
+
*/
|
|
44
|
+
export const daysInMonth = (year: number, month: number): number =>
|
|
42
45
|
month === 2
|
|
43
46
|
? (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
|
44
47
|
? 29
|
|
@@ -91,10 +94,20 @@ export function plainDateParts(date: PlainDate): PlainDateParts {
|
|
|
91
94
|
/**
|
|
92
95
|
* The calendar date an instant falls on **in a named zone** — the one conversion between the two,
|
|
93
96
|
* 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.
|
|
97
|
+
* still the 13th in Los Angeles. A local date outside years 0000-9999 is refused, as `plainDateUtc`
|
|
98
|
+
* refuses one.
|
|
95
99
|
*/
|
|
96
|
-
export
|
|
97
|
-
isoDateInZone(at, zone)
|
|
100
|
+
export function plainDateIn(at: Instant, zone: TimeZone): PlainDate {
|
|
101
|
+
const local = isoDateInZone(at, zone);
|
|
102
|
+
// Checked, not cast: an instant late on 9999-12-31 is already year 10000 east of Greenwich, and
|
|
103
|
+
// `isoDateInZone` spells that with five digits — a string `isPlainDate` rejects. `isoDateInZone`
|
|
104
|
+
// itself stays total: it returns a plain string, and `isSameLocalDay` compares two of them for
|
|
105
|
+
// instants in any year a `Date` can hold.
|
|
106
|
+
if (!isPlainDate(local)) {
|
|
107
|
+
throw scheduleInvalid('date', local, 'a calendar date in years 0000-9999');
|
|
108
|
+
}
|
|
109
|
+
return local;
|
|
110
|
+
}
|
|
98
111
|
|
|
99
112
|
/**
|
|
100
113
|
* The calendar date a `Date` holds when read as UTC. For ONE caller: a Postgres driver hands a
|
|
@@ -104,7 +117,14 @@ export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
|
|
|
104
117
|
*/
|
|
105
118
|
export function plainDateUtc(at: Date): PlainDate {
|
|
106
119
|
const checked = instant(at);
|
|
107
|
-
|
|
120
|
+
const year = checked.getUTCFullYear();
|
|
121
|
+
// The form has four year digits and no sign. A `Date` reaches 275760 and -271821, and padding
|
|
122
|
+
// those gave `10000-01-01` branded `PlainDate` — a value `isPlainDate` rejects and a Postgres
|
|
123
|
+
// `date` parameter reads as something else. Refused, so the brand is never a lie.
|
|
124
|
+
if (year < 0 || year > 9999) {
|
|
125
|
+
throw scheduleInvalid('date', checked.toISOString(), 'a calendar date in years 0000-9999');
|
|
126
|
+
}
|
|
127
|
+
return `${pad(year, 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
|
|
108
128
|
checked.getUTCDate(),
|
|
109
129
|
2,
|
|
110
130
|
)}` as PlainDate;
|
|
@@ -120,7 +140,10 @@ export const comparePlainDates = (left: PlainDate, right: PlainDate): number =>
|
|
|
120
140
|
|
|
121
141
|
const DAY_MS = 86_400_000;
|
|
122
142
|
|
|
123
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. A
|
|
145
|
+
* result outside years 0000-9999 is refused by `plainDateUtc` rather than branded.
|
|
146
|
+
*/
|
|
124
147
|
export function addPlainDays(date: PlainDate, days: number): PlainDate {
|
|
125
148
|
if (!Number.isSafeInteger(days)) {
|
|
126
149
|
throw scheduleInvalid('days', days, 'a whole number of days');
|
package/src/zone-canonical.ts
CHANGED
|
@@ -37,6 +37,11 @@ const probed = new Map<string, string | ''>();
|
|
|
37
37
|
* `'CET'` and `'+01:00'` are not zones: an abbreviation is ambiguous and an offset has no rules.
|
|
38
38
|
*/
|
|
39
39
|
export function canonicalTimeZone(zone: string): string | undefined {
|
|
40
|
+
// The type says `string`; a plain-JS caller, a JSON-driven call and an `as never` do not. Every
|
|
41
|
+
// zoned function in the package funnels through here, so this is the one place a non-string
|
|
42
|
+
// becomes "not a zone" — and so `X_TIMEZONE_INVALID` — rather than a bare `TypeError` out of
|
|
43
|
+
// `zone.toLowerCase()` several frames from the call that omitted it.
|
|
44
|
+
if (typeof zone !== 'string') return undefined;
|
|
40
45
|
if (zone === '' || NUMERIC_OFFSET.test(zone)) return undefined;
|
|
41
46
|
const known = listedZones().get(zone.toLowerCase());
|
|
42
47
|
if (known !== undefined) return known;
|
package/src/zoned.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
* instant. `fromZoned` is the DST-correct one, and it is the reason this package exists.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { dstAmbiguous, dstNonexistent } from './errors';
|
|
6
|
+
import { dstAmbiguous, dstNonexistent, scheduleInvalid } from './errors';
|
|
7
7
|
import { addMs, type Instant } from './instant';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertTimeZone,
|
|
10
|
+
offsetAt,
|
|
11
|
+
offsetLabel,
|
|
12
|
+
type TimeZone,
|
|
13
|
+
utcEpoch,
|
|
14
|
+
zonePartsAt,
|
|
15
|
+
} from './zones';
|
|
9
16
|
|
|
10
17
|
/** A local date and time with no zone attached — meaningless until paired with one. */
|
|
11
18
|
export interface WallClock {
|
|
@@ -168,8 +175,15 @@ export function endOfDay(at: Instant, zone: TimeZone): Instant {
|
|
|
168
175
|
/**
|
|
169
176
|
* Add calendar days in a zone, keeping the wall-clock time. Adding one day is 23, 24 or
|
|
170
177
|
* 25 hours depending on the transition — which is why this is not `+ 86_400_000`.
|
|
178
|
+
*
|
|
179
|
+
* A WHOLE number of days, checked as `addPlainDays` and `addBusinessDays` check theirs: `NaN`
|
|
180
|
+
* reached `Date.UTC` and came back out of `Intl` as a bare `RangeError`, and `0.5` was truncated
|
|
181
|
+
* there and moved nothing — a silent no-op that reads as an answer.
|
|
171
182
|
*/
|
|
172
183
|
export function addDaysInZone(at: Instant, days: number, zone: TimeZone): Instant {
|
|
184
|
+
if (!Number.isSafeInteger(days)) {
|
|
185
|
+
throw scheduleInvalid('days', days, 'a whole number of days');
|
|
186
|
+
}
|
|
173
187
|
const zoned = toZoned(at, zone);
|
|
174
188
|
return fromZoned(
|
|
175
189
|
{
|
|
@@ -192,6 +206,19 @@ export function isoDateInZone(at: Instant, zone: TimeZone): string {
|
|
|
192
206
|
return `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
193
207
|
}
|
|
194
208
|
|
|
209
|
+
/**
|
|
210
|
+
* `2026-03-08T03:00:00-04:00` — ISO-8601 with the ZONE's OWN offset, never collapsed to UTC `Z`
|
|
211
|
+
* (which is what `toIso` is for). The form a schedule is read in: the two passes of a fall-back
|
|
212
|
+
* hour are one wall time and two instants, and the offset is the only thing that tells them
|
|
213
|
+
* apart. Seconds precision; `fromIso` reads it back to the same second.
|
|
214
|
+
*/
|
|
215
|
+
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)}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
195
222
|
/** True when both instants fall on the same local calendar day. */
|
|
196
223
|
export function isSameLocalDay(left: Instant, right: Instant, zone: TimeZone): boolean {
|
|
197
224
|
return isoDateInZone(left, zone) === isoDateInZone(right, zone);
|
package/src/zones.ts
CHANGED
|
@@ -70,8 +70,13 @@ export function zonePartsAt(zone: TimeZone, at: Instant): ZoneParts {
|
|
|
70
70
|
if (value === undefined) throw timezoneInvalid(zone);
|
|
71
71
|
return Number.parseInt(value, 10);
|
|
72
72
|
};
|
|
73
|
+
// `Intl` writes the proleptic Gregorian ERA year: year 0 is "1 BC", year -5 is "6 BC". The era is
|
|
74
|
+
// read with it and mapped back to the astronomical year every caller computes with (`utcEpoch`,
|
|
75
|
+
// `getUTCFullYear`); without it year 0 read as 1, and every offset before AD 1 was a year off.
|
|
76
|
+
const eraYear = read('year');
|
|
77
|
+
const beforeCommonEra = parts.find((part) => part.type === 'era')?.value === BEFORE_COMMON_ERA;
|
|
73
78
|
return {
|
|
74
|
-
year:
|
|
79
|
+
year: beforeCommonEra ? 1 - eraYear : eraYear,
|
|
75
80
|
month: read('month'),
|
|
76
81
|
day: read('day'),
|
|
77
82
|
hour: read('hour') % 24,
|
|
@@ -157,6 +162,9 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
|
|
|
157
162
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
158
163
|
const labelFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
159
164
|
|
|
165
|
+
/** What the `en-US` formatter below names the era before year 1, at `era: 'short'`. */
|
|
166
|
+
const BEFORE_COMMON_ERA = 'BC';
|
|
167
|
+
|
|
160
168
|
function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
161
169
|
// Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
|
|
162
170
|
const canonical = canonicalTimeZone(zone);
|
|
@@ -168,6 +176,7 @@ function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
|
|
|
168
176
|
new Intl.DateTimeFormat('en-US', {
|
|
169
177
|
timeZone: canonical,
|
|
170
178
|
hourCycle: 'h23',
|
|
179
|
+
era: 'short',
|
|
171
180
|
year: 'numeric',
|
|
172
181
|
month: '2-digit',
|
|
173
182
|
day: '2-digit',
|