@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/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
- let remaining = Math.abs(Math.round(ms));
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
- const total = Math.abs(Math.round(ms));
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
- // `value` is whatever a caller put in a `LocalSlot` — this factory is exported, so it is a form
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: string): TimeError {
94
+ export function timezoneInvalid(zone: unknown): TimeError {
73
95
  return new TimeError({
74
96
  code: 'X_TIMEZONE_INVALID',
75
- cause: `"${zone}" is not an IANA Area/Location zone name`,
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: assertTimeZone(options.zone),
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: assertTimeZone(options.zone),
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: assertTimeZone(options.zone),
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: assertTimeZone(options.zone),
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 Omit<FormatContext, 'zone'> {
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 RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
111
- ['year', 31_536_000_000],
112
- ['month', 2_592_000_000],
113
- ['week', 604_800_000],
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
- /** `in 3 days` / `2 hours ago`, picking the largest unit that fits. */
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
- for (const [unit, ms] of RELATIVE_UNITS) {
129
- if (magnitude >= ms) {
130
- return formatter.format(Math.trunc(delta / ms), unit);
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: assertTimeZone(options.zone),
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
  /**
@@ -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. Re-exported
5
- // rather than dropped so this package's public surface is unchanged, the same way
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
- /** Days in a month, Gregorian. February is the only interesting one. */
41
- const daysInMonth = (year: number, month: number): number =>
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 const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
97
- isoDateInZone(at, zone) as PlainDate;
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
- return `${pad(checked.getUTCFullYear(), 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
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
- /** Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. */
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');
@@ -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 { assertTimeZone, offsetAt, type TimeZone, utcEpoch, zonePartsAt } from './zones';
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: read('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',