@ultimat3/time 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -143,6 +143,28 @@
143
143
  applies ONLY when day-of-month is restricted and day-of-week is not: Vixie's OR means
144
144
  `0 0 30 2 5` fires every Friday in February, so refusing it would break a working schedule.
145
145
  `MAX_STEPS` stays as the backstop for what the check cannot see.
146
+ - **The repeated hour of a fall-back night is run through by an INTERVAL and fired once by a FIXED
147
+ time, `As of 2026-10`.** `CronExpression.wildcardTime` — the minute or hour field spelled `*` or
148
+ `*/n`, Vixie's test — is the whole decision. The wall-clock walk alone cannot see the hour come
149
+ round again (from 02:55 it reaches 03:00), so `*/5 * * * *` went dark 01:00Z–02:00Z in Berlin
150
+ once a year while `matchesCron` answered `true` inside it. `nextCronOccurrence` walks a second
151
+ time from the wall clock the transition lands on. A fixed time never returns its second pass,
152
+ from ANY `after` — `@ultimat3/jobs`' `latestOccurrence` bisects over `after`, so the answer must
153
+ be monotone in it; `cron-occurrence.test.ts` pins that. `matchesCron` stays the pure wall-clock
154
+ predicate and is `true` for a fixed time's second pass: it answers "does this instant's clock
155
+ match", not "is this an occurrence".
156
+ - **`addBusinessDays` builds every candidate from the calendar date + the ORIGINAL wall time.**
157
+ Chained through `addDaysInZone`, a 02:30 that crossed a spring-forward Sunday became 03:30 and
158
+ stayed 03:30 on Monday. `businessDaysBetween` walks the same dates (`localDay`): a chain steps
159
+ OVER a date the zone never had (Samoa, 2011-12-30) and so counted the day `to` excludes. A date
160
+ that does not exist is not a day of either kind.
161
+ - **`formatRelative` takes `zone`, and from a day apart counts CALENDAR days** (`daysBetween`).
162
+ `trunc(47h / 24h)` called two midnights away "tomorrow". Under a day it is elapsed time; a full
163
+ day apart with no midnight crossed (a 25-hour day) is hours, not "today". **Breaking.**
164
+ - **A cron name matches EXACTLY** — its three letters or its whole word. First-three-letters
165
+ matching read `marzipan` as March. Extra `-` and `/` parts are refused, not dropped.
166
+ - **A `PlainDate` has four year digits.** `plainDateUtc` (and so `addPlainDays`) and `plainDateIn`
167
+ refuse a year outside 0000-9999 rather than brand `10000-01-01`, which `isPlainDate` rejects.
146
168
  - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
147
169
  - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
148
170
  - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
package/README.md CHANGED
@@ -58,6 +58,11 @@ formatWithOffset(at, { locale: 'en-GB', zone: 'America/New_York' });
58
58
  nextLocalSlot({ zone: 'Asia/Kathmandu', hour: 9 }, now(clock)); // 09:00 local, +05:45 handled
59
59
  ```
60
60
 
61
+ `formatRelative(at, { locale, zone, now })` — `in 3 days`, `2 hours ago`. `zone` is required: from
62
+ a day apart the number is **calendar days in that zone** (`daysBetween`), so 47 hours ahead across
63
+ two midnights is `in 2 days`, never `tomorrow`. Under a day apart it is elapsed hours, minutes or
64
+ seconds.
65
+
61
66
  ## DST is a policy, not a guess
62
67
 
63
68
  `fromZoned(wall, zone, { gap, overlap })` is the function everything else is built on.
@@ -84,9 +89,10 @@ one of these takes the zone explicitly, and none of them has a default.
84
89
  | Function | Answers |
85
90
  |---|---|
86
91
  | `startOfDay(at, zone)` / `endOfDay(at, zone)` | local midnight; the last millisecond of the day |
87
- | `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away |
92
+ | `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away. `days` is a whole number — `NaN` and `0.5` are `X_SCHEDULE_INVALID` |
88
93
  | `daysBetween(from, to, zone)` | local day boundaries crossed — signed, always integral |
89
94
  | `isoDateInZone(at, zone)` | `2026-03-14` |
95
+ | `isoInZone(at, zone)` | `2026-03-08T03:00:00-04:00` — ISO-8601 with the zone's own offset, never `Z` for a non-UTC zone. `toIso` is the UTC form |
90
96
  | `isSameLocalDay(left, right, zone)` | whether two instants share a local date |
91
97
 
92
98
  `daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
@@ -109,7 +115,7 @@ half the planet.
109
115
  | `plainDateIn(at, zone)` | the calendar date an **instant** falls on, in a named zone. It takes the zone because there is no other honest way to make this conversion |
110
116
  | `plainDateUtc(at)` | the date a `Date` holds read as UTC — for the one caller that needs it: a Postgres driver returns a `date` column as midnight UTC |
111
117
  | `plainDateToUtcInstant(date)` | midnight UTC of the date. The inverse of `plainDateUtc`, and never of `plainDateIn` |
112
- | `addPlainDays(date, days)` / `plainDaysBetween(from, to)` | calendar arithmetic with no zone in it: a DST day is one day, because there is no zone to shorten |
118
+ | `addPlainDays(date, days)` / `plainDaysBetween(from, to)` | calendar arithmetic with no zone in it: a DST day is one day, because there is no zone to shorten. A result outside years `0000`-`9999` is `X_SCHEDULE_INVALID` |
113
119
  | `comparePlainDates(a, b)` | `-1` / `0` / `1` |
114
120
 
115
121
  It is a branded **string**, and both halves are load-bearing. Not a `Date`: a `Date` is an instant,
@@ -126,7 +132,18 @@ through `JSON.stringify` as itself, and is the literal Postgres accepts and retu
126
132
  macros, and Vixie's dom/dow OR rule. `nextCronOccurrence(expr, zone, after)` iterates the
127
133
  zone's **wall clock**, so `0 3 * * *` in `Europe/Berlin` stays at 03:00 local across a DST
128
134
  change, and a job scheduled inside the gap runs at the first existing local time instead of
129
- being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
135
+ being skipped. A name is its three letters or its whole word (`mon`, `monday`) — never a prefix
136
+ match — and a range has two ends, a step one: `marzipan`, `1-5-7` and `1/2/3` are `X_CRON_INVALID`.
137
+
138
+ On a fall-back night the repeated hour is decided by what the expression names
139
+ (`CronExpression.wildcardTime`, Vixie's rule):
140
+
141
+ | Expression | In the repeated hour |
142
+ |---|---|
143
+ | a fixed time — `30 2 * * *` | fires **once**, on the first pass, whichever instant it is asked from |
144
+ | an interval — minute or hour field `*` or `*/n`: `*/5 * * * *`, `0 * * * *` | runs through **both** passes; no hour of the night is skipped |
145
+
146
+ `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
130
147
  weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
131
148
  because tier 1 cannot reach `t()` and a built-in default would ship English to every locale. A
132
149
  long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
@@ -139,6 +156,10 @@ ten-second step used to render as "every minute".
139
156
  The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
140
157
  `WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
141
158
 
159
+ `addBusinessDays(at, days, calendar)` keeps the **original** wall-clock time: every candidate is the
160
+ calendar date plus the time the caller started with, so a spring-forward day on the way does not
161
+ shift the days after it.
162
+
142
163
  `businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
143
164
  days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
144
165
  neither endpoint's wall-clock time is part of the question. A reversed range is the same count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "!src/**/*-fixture.ts",
25
26
  "CLAUDE.md",
26
27
  "README.md",
27
28
  "LICENSE"
@@ -34,7 +35,7 @@
34
35
  "test": "bun test"
35
36
  },
36
37
  "dependencies": {
37
- "@ultimat3/core": "22.15.0",
38
- "@ultimat3/schema": "22.15.0"
38
+ "@ultimat3/core": "24.0.0",
39
+ "@ultimat3/schema": "24.0.0"
39
40
  }
40
41
  }
package/src/business.ts CHANGED
@@ -5,8 +5,8 @@
5
5
 
6
6
  import { scheduleInvalid } from './errors';
7
7
  import type { Instant } from './instant';
8
- import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
9
- import type { TimeZone } from './zones';
8
+ import { daysBetween, fromZoned, isoDateInZone, toZoned, type ZonedDateTime } from './zoned';
9
+ import { type TimeZone, utcEpoch } from './zones';
10
10
 
11
11
  /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
12
12
  export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
@@ -57,18 +57,56 @@ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCal
57
57
  }
58
58
  if (days === 0) return at;
59
59
  const step = days > 0 ? 1 : -1;
60
+ const origin = toZoned(at, calendar.zone);
60
61
  let remaining = Math.abs(days);
61
- let cursor = at;
62
+ let offset = 0;
62
63
  let guard = 0;
64
+ let landed = at;
63
65
 
64
66
  while (remaining > 0) {
65
- cursor = addDaysInZone(cursor, step, calendar.zone);
66
- if (isBusinessDay(cursor, calendar)) remaining -= 1;
67
+ offset += step;
68
+ const candidate = localDay(origin, offset, calendar.zone);
69
+ if (candidate !== null && isBusinessDay(candidate, calendar)) {
70
+ remaining -= 1;
71
+ landed = candidate;
72
+ }
67
73
  guard += 1;
68
74
  // A calendar with every day marked as a holiday would otherwise loop forever.
69
- if (guard > Math.abs(days) * 7 + 3650) return cursor;
75
+ if (guard > Math.abs(days) * 7 + 3650) return localDay(origin, offset, calendar.zone) ?? landed;
70
76
  }
71
- return cursor;
77
+ return landed;
78
+ }
79
+
80
+ /**
81
+ * The calendar date `offset` days from `origin`'s, at `origin`'s wall time — or `null` when the
82
+ * zone never had that date (Samoa's 2011-12-30).
83
+ *
84
+ * Built from the calendar date and the ORIGINAL wall time, never from the day before it. Chaining
85
+ * `addDaysInZone` a day at a time carried a DST repair forward: 02:30 through a spring-forward
86
+ * Sunday became 03:30 there and stayed 03:30 on Monday, where 02:30 exists. `fromZoned` normalizes
87
+ * the day overflow, so `day + offset` is the whole walk.
88
+ */
89
+ function localDay(origin: ZonedDateTime, offset: number, zone: TimeZone): Instant | null {
90
+ const candidate = fromZoned(
91
+ {
92
+ year: origin.year,
93
+ month: origin.month,
94
+ day: origin.day + offset,
95
+ hour: origin.hour,
96
+ minute: origin.minute,
97
+ second: origin.second,
98
+ millisecond: origin.millisecond,
99
+ },
100
+ zone,
101
+ { gap: 'next' },
102
+ );
103
+ const wanted = new Date(utcEpoch(origin.year, origin.month, origin.day + offset));
104
+ const got = toZoned(candidate, zone);
105
+ const exists =
106
+ got.year === wanted.getUTCFullYear() &&
107
+ got.month === wanted.getUTCMonth() + 1 &&
108
+ got.day === wanted.getUTCDate();
109
+ return exists ? candidate : null;
72
110
  }
73
111
 
74
112
  /** The next business day at the same local time; today if it already qualifies. */
@@ -97,13 +135,14 @@ export function businessDaysBetween(
97
135
  const start = sign === 1 ? from : to;
98
136
  const end = sign === 1 ? to : from;
99
137
  // `daysBetween` is the day count of the same half-open interval, so it is also the exact number
100
- // of iterations — the loop cannot run away on a calendar `addDaysInZone` handles oddly.
138
+ // of iterations. Each one is a calendar DATE counted from `start`'s — not the instant a day after
139
+ // the previous one, which steps over a date the zone skipped and so reaches past `to`.
101
140
  const days = daysBetween(start, end, calendar.zone);
102
- let cursor = start;
141
+ const origin = toZoned(start, calendar.zone);
103
142
  let count = 0;
104
143
  for (let index = 0; index < days; index += 1) {
105
- if (isBusinessDay(cursor, calendar)) count += 1;
106
- cursor = addDaysInZone(cursor, 1, calendar.zone);
144
+ const day = localDay(origin, index, calendar.zone);
145
+ if (day !== null && isBusinessDay(day, calendar)) count += 1;
107
146
  }
108
147
  // Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
109
148
  // `-0`, which `Object.is`, a `Map` key and every `toBe` treat as a value distinct from `0`.
package/src/context.ts CHANGED
@@ -57,11 +57,12 @@ let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
57
57
  * request, but a default nothing can fall back to is a boot-time mistake with no second answer.
58
58
  */
59
59
  export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
60
- const defaultZone =
61
- partial.defaultZone === undefined ? undefined : assertTimeZone(partial.defaultZone);
60
+ // `defaultZone: undefined` means "not given" and must not be spread over the zone in force.
61
+ const { defaultZone: given, ...rest } = partial;
62
+ const defaultZone = given === undefined ? undefined : assertTimeZone(given);
62
63
  config = {
63
64
  ...config,
64
- ...partial,
65
+ ...rest,
65
66
  ...(defaultZone === undefined ? {} : { defaultZone }),
66
67
  };
67
68
  return config;
@@ -7,7 +7,7 @@ import { type CronExpression, matchesDay, parseCronOnce } from './cron-parse';
7
7
  import { cronInvalid } from './errors';
8
8
  import { fromEpochMs, type Instant } from './instant';
9
9
  import { fromZoned, toZoned } from './zoned';
10
- import { assertTimeZone, type TimeZone, utcEpoch } from './zones';
10
+ import { assertTimeZone, offsetAt, type TimeZone, utcEpoch } from './zones';
11
11
 
12
12
  /** True when `at` matches the expression in `zone`, to the second. */
13
13
  export function matchesCron(
@@ -38,7 +38,15 @@ const MAX_STEPS = 200_000;
38
38
  * and converted with `fromZoned({ gap: 'next', overlap: 'first' })`:
39
39
  * - spring forward: a 02:30 daily job runs once, at the first existing local time after
40
40
  * the gap, instead of being silently skipped;
41
- * - fall back: a repeated local time runs once, on its first occurrence.
41
+ * - fall back, a FIXED time (`30 2 * * *`): the repeated local time runs once, on its first
42
+ * occurrence — asked from anywhere, including from inside the repeated hour;
43
+ * - fall back, an INTERVAL (every five minutes, `0 * * * *` — `CronExpression.wildcardTime`):
44
+ * the repeated hour is real elapsed time and the schedule runs through both passes of it.
45
+ *
46
+ * The last two are Vixie cron's rule, and they are one rule about what the author named: a fixed
47
+ * time names a moment of the day, an interval names a rhythm. The wall clock alone could not say
48
+ * it — walking it forward from 02:55 reaches 03:00 and never sees 02:00 come round again, so
49
+ * every interval schedule went dark for one hour a year.
42
50
  */
43
51
  export function nextCronOccurrence(
44
52
  expression: string | CronExpression,
@@ -47,16 +55,77 @@ export function nextCronOccurrence(
47
55
  ): Instant {
48
56
  const cron = parseCronOnce(expression);
49
57
  assertTimeZone(zone);
58
+ const source = typeof expression === 'string' ? expression : cron.source;
50
59
 
51
60
  const start = toZoned(after, zone);
52
- const cursor: Cursor = {
61
+ const first = walk(cron, zone, source, after, {
53
62
  year: start.year,
54
63
  month: start.month,
55
64
  day: start.day,
56
65
  hour: start.hour,
57
66
  minute: start.minute,
58
67
  second: start.second + 1,
59
- };
68
+ });
69
+ if (!cron.wildcardTime) return first;
70
+
71
+ // The second pass of an overlap that begins AFTER `after`. Its wall times are behind the cursor
72
+ // the first walk started from, so that walk cannot have met them; they are met here, by walking
73
+ // again from the wall clock the transition lands on. Only a transition inside the overlap's own
74
+ // reach matters — past it, every repeated wall time has a first-pass instant that is itself
75
+ // after `after`, and the first walk already answered with something at or before it.
76
+ const horizon = Math.min(first.getTime(), after.getTime() + OVERLAP_REACH_MS);
77
+ const offsetBefore = offsetAt(zone, after);
78
+ if (offsetAt(zone, fromEpochMs(horizon)) >= offsetBefore) return first;
79
+ const landed = toZoned(fromEpochMs(fallBackAt(zone, after.getTime(), horizon)), zone);
80
+ const repeated = walk(
81
+ cron,
82
+ zone,
83
+ source,
84
+ after,
85
+ {
86
+ year: landed.year,
87
+ month: landed.month,
88
+ day: landed.day,
89
+ hour: landed.hour,
90
+ minute: landed.minute,
91
+ second: landed.second,
92
+ },
93
+ 'second',
94
+ );
95
+ return repeated.getTime() < first.getTime() ? repeated : first;
96
+ }
97
+
98
+ /**
99
+ * How far ahead of `after` a fall-back can begin and still repeat a wall time `after` has already
100
+ * passed. A day and a bit: no zone's clock has ever been set back by more than a day.
101
+ */
102
+ const OVERLAP_REACH_MS = 26 * 3_600_000;
103
+
104
+ /** The first epoch ms in `(from, to]` whose offset is below `from`'s — found by bisection. */
105
+ function fallBackAt(zone: TimeZone, from: number, to: number): number {
106
+ const before = offsetAt(zone, fromEpochMs(from));
107
+ let lo = from;
108
+ let hi = to;
109
+ while (hi - lo > 1) {
110
+ const mid = lo + Math.floor((hi - lo) / 2);
111
+ if (offsetAt(zone, fromEpochMs(mid)) < before) hi = mid;
112
+ else lo = mid;
113
+ }
114
+ return hi;
115
+ }
116
+
117
+ /**
118
+ * Walk the wall clock forward from `cursor` (inclusive) to the first matching wall time whose
119
+ * instant is after `after`. `overlap` is which pass a repeated wall time resolves to FIRST.
120
+ */
121
+ function walk(
122
+ cron: CronExpression,
123
+ zone: TimeZone,
124
+ source: string,
125
+ after: Instant,
126
+ cursor: Cursor,
127
+ overlap: 'first' | 'second' = 'first',
128
+ ): Instant {
60
129
  carry(cursor);
61
130
 
62
131
  for (let step = 0; step < MAX_STEPS; step += 1) {
@@ -99,8 +168,14 @@ export function nextCronOccurrence(
99
168
  continue;
100
169
  }
101
170
 
102
- const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'first' });
171
+ const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap });
103
172
  if (candidate.getTime() > after.getTime()) return candidate;
173
+ // `after` is INSIDE the second pass of a repeated hour, so the first pass of this wall time is
174
+ // behind it. An interval schedule's next run is the second pass; a fixed time already ran.
175
+ if (cron.wildcardTime && overlap === 'first') {
176
+ const later = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'second' });
177
+ if (later.getTime() > after.getTime()) return later;
178
+ }
104
179
  // The DST gap can push a candidate onto an instant we have already passed; step on.
105
180
  cursor.second += 1;
106
181
  carry(cursor);
@@ -110,7 +185,7 @@ export function nextCronOccurrence(
110
185
  // February — is refused by `parseCron` in constant time, because reaching it here cost ~150ms of
111
186
  // blocking CPU per call and `firedSince` pays that per tick of the scheduler's leader loop.
112
187
  throw cronInvalid(
113
- typeof expression === 'string' ? expression : cron.source,
188
+ source,
114
189
  `no occurrence after ${MAX_STEPS} search steps — the date fields can never all match`,
115
190
  );
116
191
  }
package/src/cron-parse.ts CHANGED
@@ -18,6 +18,11 @@ export interface CronExpression {
18
18
  /** Vixie semantics: when both day fields are restricted, either one matching is a hit. */
19
19
  dayOfMonthRestricted: boolean;
20
20
  dayOfWeekRestricted: boolean;
21
+ // The minute or the hour field is `*` or a stepped `*` — an INTERVAL, not a time of day. Vixie's
22
+ // test, and it decides one thing: whether the repeated hour of a fall-back night is run through
23
+ // (every five minutes) or fired once (02:30 daily). A fact about how the field was spelled,
24
+ // which the value lists cannot carry — `0-59` and `*` hold the same sixty numbers.
25
+ wildcardTime: boolean;
21
26
  }
22
27
 
23
28
  // A `Map`, not an object literal: the key is a caller's string, and `MACROS['constructor']` on a
@@ -50,6 +55,7 @@ const MONTH_NAMES = [
50
55
  'dec',
51
56
  ];
52
57
  const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
58
+ const DAY_LABELS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
53
59
 
54
60
  /** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
55
61
  export function parseCron(expression: string): CronExpression {
@@ -69,10 +75,10 @@ export function parseCron(expression: string): CronExpression {
69
75
  const minutes = parseField(expression, minuteField ?? '*', 0, 59);
70
76
  const hours = parseField(expression, hourField ?? '*', 0, 23);
71
77
  const daysOfMonth = parseField(expression, domField ?? '*', 1, 31);
72
- const months = parseField(expression, monthField ?? '*', 1, 12, MONTH_NAMES, 1);
78
+ const months = parseField(expression, monthField ?? '*', 1, 12, [MONTH_NAMES, MONTH_LABELS], 1);
73
79
  // Span 7, not `max - min + 1` = 8: the dow field accepts 0-7 because Sunday has two spellings,
74
80
  // so the modulus a wrap strides over is a week with a phantom day in it unless it is stated.
75
- const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0, 7);
81
+ const rawDow = parseField(expression, dowField ?? '*', 0, 7, [DAY_NAMES, DAY_LABELS], 0, 7);
76
82
 
77
83
  // 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
78
84
  const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
@@ -87,6 +93,7 @@ export function parseCron(expression: string): CronExpression {
87
93
  daysOfWeek,
88
94
  dayOfMonthRestricted: isRestricted(domField ?? '*'),
89
95
  dayOfWeekRestricted: isRestricted(dowField ?? '*'),
96
+ wildcardTime: isWildcard(minuteField ?? '*') || isWildcard(hourField ?? '*'),
90
97
  };
91
98
  assertReachableDate(expression, parsed);
92
99
  return parsed;
@@ -164,7 +171,7 @@ function parseField(
164
171
  field: string,
165
172
  min: number,
166
173
  max: number,
167
- names: readonly string[] = [],
174
+ names: readonly (readonly string[])[] = [],
168
175
  nameOffset = 0,
169
176
  /**
170
177
  * How many distinct values one full turn of this field has — `max - min + 1` for every field
@@ -177,8 +184,11 @@ function parseField(
177
184
  const values = new Set<number>();
178
185
  for (const part of field.split(',')) {
179
186
  if (part === '') throw cronInvalid(expression, `empty list item in "${field}"`);
180
- const [rangePart, stepPart] = part.split('/');
181
- if (rangePart === undefined || (part.includes('/') && stepPart === undefined)) {
187
+ // Destructuring the split read the first two pieces and dropped the rest, so `1/2/3` was
188
+ // `1/2` and `1-5-7` was `1-5`: a typo that validated and scheduled something else.
189
+ const stepParts = part.split('/');
190
+ const [rangePart, stepPart] = stepParts;
191
+ if (rangePart === undefined || stepParts.length > 2) {
182
192
  throw cronInvalid(expression, `malformed step in "${part}"`);
183
193
  }
184
194
  const step = stepPart === undefined ? 1 : parseInteger(stepPart);
@@ -192,7 +202,11 @@ function parseField(
192
202
  from = min;
193
203
  to = max;
194
204
  } else if (rangePart.includes('-')) {
195
- const [left, right] = rangePart.split('-');
205
+ const ends = rangePart.split('-');
206
+ if (ends.length !== 2) {
207
+ throw cronInvalid(expression, `a range has two ends, got ${ends.length} in "${part}"`);
208
+ }
209
+ const [left, right] = ends;
196
210
  from = toNumber(expression, left, min, max, names, nameOffset);
197
211
  to = toNumber(expression, right, min, max, names, nameOffset);
198
212
  } else {
@@ -221,13 +235,13 @@ function toNumber(
221
235
  token: string | undefined,
222
236
  min: number,
223
237
  max: number,
224
- names: readonly string[],
238
+ names: readonly (readonly string[])[],
225
239
  nameOffset: number,
226
240
  ): number {
227
241
  if (token === undefined || token === '') throw cronInvalid(expression, 'missing value');
228
- // Names match on their first three letters, so `mon` and `monday` both work — but a token
229
- // carrying anything that is not a letter is a typo, not a name, and falls through to digits.
230
- const named = /^[a-z]+$/.test(token) ? names.indexOf(token.slice(0, 3)) : -1;
242
+ // A name is its three-letter form or its whole word — `mon` and `monday` — matched EXACTLY.
243
+ // Matching on the first three letters read `marzipan` as March and `monkey` as Monday.
244
+ const named = names.reduce((found, list) => (found === -1 ? list.indexOf(token) : found), -1);
231
245
  const value = named === -1 ? parseInteger(token) : named + nameOffset;
232
246
  if (value === undefined) {
233
247
  throw cronInvalid(expression, `"${token}" is not a number or a name`);
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
@@ -69,10 +69,12 @@ export function scheduleInvalid(field: string, value: unknown, range: string): T
69
69
  * abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label
70
70
  * link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction.
71
71
  */
72
- export function timezoneInvalid(zone: string): TimeError {
72
+ export function timezoneInvalid(zone: unknown): TimeError {
73
73
  return new TimeError({
74
74
  code: 'X_TIMEZONE_INVALID',
75
- cause: `"${zone}" is not an IANA Area/Location zone name`,
75
+ // `unknown`, because the refusal is reached by exactly the callers the `string` type did not
76
+ // stop — an omitted `zone` is `undefined` here. A string keeps its quoted spelling.
77
+ cause: `${typeof zone === 'string' ? `"${zone}"` : renderCauseValue(zone)} is not an IANA Area/Location zone name`,
76
78
  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
79
  });
78
80
  }
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
  /**
package/src/index.ts CHANGED
@@ -138,6 +138,7 @@ export {
138
138
  fromZonedDetailed,
139
139
  type GapPolicy,
140
140
  isoDateInZone,
141
+ isoInZone,
141
142
  isSameLocalDay,
142
143
  type OverlapPolicy,
143
144
  startOfDay,
package/src/plain-date.ts CHANGED
@@ -91,10 +91,20 @@ export function plainDateParts(date: PlainDate): PlainDateParts {
91
91
  /**
92
92
  * The calendar date an instant falls on **in a named zone** — the one conversion between the two,
93
93
  * 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.
94
+ * still the 13th in Los Angeles. A local date outside years 0000-9999 is refused, as `plainDateUtc`
95
+ * refuses one.
95
96
  */
96
- export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
97
- isoDateInZone(at, zone) as PlainDate;
97
+ export function plainDateIn(at: Instant, zone: TimeZone): PlainDate {
98
+ const local = isoDateInZone(at, zone);
99
+ // Checked, not cast: an instant late on 9999-12-31 is already year 10000 east of Greenwich, and
100
+ // `isoDateInZone` spells that with five digits — a string `isPlainDate` rejects. `isoDateInZone`
101
+ // itself stays total: it returns a plain string, and `isSameLocalDay` compares two of them for
102
+ // instants in any year a `Date` can hold.
103
+ if (!isPlainDate(local)) {
104
+ throw scheduleInvalid('date', local, 'a calendar date in years 0000-9999');
105
+ }
106
+ return local;
107
+ }
98
108
 
99
109
  /**
100
110
  * The calendar date a `Date` holds when read as UTC. For ONE caller: a Postgres driver hands a
@@ -104,7 +114,14 @@ export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
104
114
  */
105
115
  export function plainDateUtc(at: Date): PlainDate {
106
116
  const checked = instant(at);
107
- return `${pad(checked.getUTCFullYear(), 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
117
+ const year = checked.getUTCFullYear();
118
+ // The form has four year digits and no sign. A `Date` reaches 275760 and -271821, and padding
119
+ // those gave `10000-01-01` branded `PlainDate` — a value `isPlainDate` rejects and a Postgres
120
+ // `date` parameter reads as something else. Refused, so the brand is never a lie.
121
+ if (year < 0 || year > 9999) {
122
+ throw scheduleInvalid('date', checked.toISOString(), 'a calendar date in years 0000-9999');
123
+ }
124
+ return `${pad(year, 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
108
125
  checked.getUTCDate(),
109
126
  2,
110
127
  )}` as PlainDate;
@@ -120,7 +137,10 @@ export const comparePlainDates = (left: PlainDate, right: PlainDate): number =>
120
137
 
121
138
  const DAY_MS = 86_400_000;
122
139
 
123
- /** Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. */
140
+ /**
141
+ * Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. A
142
+ * result outside years 0000-9999 is refused by `plainDateUtc` rather than branded.
143
+ */
124
144
  export function addPlainDays(date: PlainDate, days: number): PlainDate {
125
145
  if (!Number.isSafeInteger(days)) {
126
146
  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);