@ultimat3/time 3.0.0 → 4.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 +46 -0
- package/README.md +5 -0
- package/package.json +2 -2
- package/src/business.ts +9 -0
- package/src/context.ts +19 -2
- package/src/cron-occurrence.ts +4 -1
- package/src/cron-parse.ts +62 -11
- package/src/duration.ts +32 -3
- package/src/format.ts +22 -13
- package/src/instant.ts +28 -4
package/CLAUDE.md
CHANGED
|
@@ -67,6 +67,52 @@
|
|
|
67
67
|
pins the process to UTC, so that bug is invisible to every in-process test — `plain-date.test.ts`
|
|
68
68
|
spawns a subprocess with `TZ=America/Los_Angeles` for exactly one assertion, and that is the only
|
|
69
69
|
reason it can fail.
|
|
70
|
+
- **`fromIso` refuses a clock time with no offset.** `new Date('2026-03-14T09:00')` is the
|
|
71
|
+
PROCESS's 09:00, so one CSV row imported on two pods becomes two instants — the ambient default
|
|
72
|
+
this package exists to abolish, inside its own entry point. `Z` or an offset, or `X_INSTANT_INVALID`;
|
|
73
|
+
wall-clock input is `fromZoned(wall, zone)`, which names its zone. A date-only form carries no
|
|
74
|
+
clock time and is UTC by specification, so it still parses.
|
|
75
|
+
- **`fromEpochMs` checks the `Date` RANGE, not `Number.isFinite`.** ±8.64e15 ms is the limit, so a
|
|
76
|
+
finite `1e16` used to hand back an Invalid Date branded as an `Instant` — a value `isInstant`
|
|
77
|
+
answers `false` for (the type's own predicate rejecting what its constructor certified) and
|
|
78
|
+
`toIso` throws a bare `RangeError` out of. `fromEpochSeconds`, `addMs`, `subtractMs` and
|
|
79
|
+
`now(clock)` all reach it and none re-checks, so the one test is `new Date(ms).getTime()` being
|
|
80
|
+
NaN — exactly what `instant()` already does.
|
|
81
|
+
- **`configureTime({ defaultZone })` goes through `assertTimeZone`**, which validates AND
|
|
82
|
+
canonicalizes. It did neither: `'Mars/Olympus'` was accepted at boot and first refused inside a
|
|
83
|
+
formatter at render time, from a stack naming no configuration, and `'eUrOpE/bErLiN'` travelled
|
|
84
|
+
the process as its own zone string minting a permanent entry in every formatter cache. It throws
|
|
85
|
+
where `resolveTimeZone` skips — a stale header must not fail a request, a default nothing can
|
|
86
|
+
fall back to is a boot-time mistake with no second answer.
|
|
87
|
+
- **`formatIsoDate` is built from `isoDateInZone`, never from `Intl` directly.** `year: 'numeric'`
|
|
88
|
+
neither zero-pads a year below 1000 nor carries the era, so it answered `'50-01-01'` where
|
|
89
|
+
`isoDateInZone` answered `'0050-01-01'` — two functions in one package answering one question
|
|
90
|
+
differently, and the short form matches no ISO pattern and is rejected by the `<input type="date">`
|
|
91
|
+
it exists for. One padding rule, in one place.
|
|
92
|
+
- **`ordinal(value)` takes no locale.** It used to accept one, select the plural CATEGORY with it,
|
|
93
|
+
and append the ENGLISH suffix for that category regardless: `ordinal(1, 'de')` was `'1th'`. A
|
|
94
|
+
parameter that cannot change the answer correctly is removed, so a caller wanting a localized
|
|
95
|
+
ordinal hears it from `tsc`. **Breaking.**
|
|
96
|
+
- **A day count is a whole number, and `formatDuration`'s `maxUnits` is at least 1.**
|
|
97
|
+
`addBusinessDays(at, 0.5)` moved a whole day and `NaN` returned the input unchanged, which reads
|
|
98
|
+
as "no movement was needed"; `maxUnits: 0` made the ceiling test true before the first unit, so
|
|
99
|
+
every duration rendered as "0 sec". Both refuse through `scheduleInvalid`, the in-package generic
|
|
100
|
+
range refusal `addPlainDays` already uses — not clamped, for the reason that error's own fix line
|
|
101
|
+
gives.
|
|
102
|
+
- **`toSeconds` carries the sign out and rounds the MAGNITUDE.** `Math.round` breaks ties toward
|
|
103
|
+
`+Infinity`, so `'1500ms'` was 2 and `'-1500ms'` was -1. `@ultimat3/money`'s `rounding.ts` is the
|
|
104
|
+
framework's statement of this, and its `signed()` is why zero never comes back as `-0`.
|
|
105
|
+
- **`parseDuration` rejects an ISO body by its GROUPS, never by its total.** `'PT0S'` — the
|
|
106
|
+
canonical zero most emitters write — was refused along with `'PT0H0M0S'` and `'P0W'`, while
|
|
107
|
+
`'P0D'` was let through by a special case. `ISO_8601` already requires a component group for any
|
|
108
|
+
body past a bare `'P'`, which is the case the guard was written for.
|
|
109
|
+
- **An impossible day/month pair is refused by `parseCron`, in constant time.**
|
|
110
|
+
`isValidCron('0 0 30 2 *')` answered `true` and the refusal arrived ~150 ms later out of
|
|
111
|
+
`nextCronOccurrence`, after 200,000 walk steps — a cost `firedSince` pays on every tick of the
|
|
112
|
+
scheduler's leader loop. February is 29 in the table because leap years happen, and the check
|
|
113
|
+
applies ONLY when day-of-month is restricted and day-of-week is not: Vixie's OR means
|
|
114
|
+
`0 0 30 2 5` fires every Friday in February, so refusing it would break a working schedule.
|
|
115
|
+
`MAX_STEPS` stays as the backstop for what the check cannot see.
|
|
70
116
|
- Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
|
|
71
117
|
- Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
|
|
72
118
|
- Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
|
package/README.md
CHANGED
|
@@ -28,6 +28,11 @@ canonicalization as a distinct string, so the key bounds nothing on its own. Bot
|
|
|
28
28
|
`canonicalLocale` and `cachedFormatter` — are `@ultimat3/core`'s as of 2.0.0, so `@ultimat3/money`
|
|
29
29
|
reads the same bound rather than a copy of it.
|
|
30
30
|
|
|
31
|
+
`fromIso` refuses a bare local timestamp. `2026-03-14T09:00` names a different instant on every
|
|
32
|
+
pod, because `new Date` resolves it through the process's zone — so a clock time reaches an
|
|
33
|
+
`Instant` only with `Z` or an offset beside it (`X_INSTANT_INVALID`), and wall-clock input goes
|
|
34
|
+
through `fromZoned(wall, zone)`, which names the zone it is stated in.
|
|
35
|
+
|
|
31
36
|
Every value this package hands back is its own object: `instant(date)` copies rather than branding
|
|
32
37
|
the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
|
|
33
38
|
mutable `Date` that a single `setUTCFullYear` corrupted for the whole process.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "4.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/business.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { scheduleInvalid } from './errors';
|
|
6
7
|
import type { Instant } from './instant';
|
|
7
8
|
import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
|
|
8
9
|
import type { TimeZone } from './zones';
|
|
@@ -44,8 +45,16 @@ export function isBusinessDay(at: Instant, calendar: BusinessCalendar): boolean
|
|
|
44
45
|
* Move `days` business days forward (or back, if negative), keeping the local wall-clock
|
|
45
46
|
* time. `days === 0` returns the input untouched, even on a weekend — callers that want
|
|
46
47
|
* "the next business day" should ask for 1.
|
|
48
|
+
*
|
|
49
|
+
* The count is a WHOLE number of days, checked the way `plain-date.ts`'s `addPlainDays` checks its
|
|
50
|
+
* own: `0.5` used to reach `Math.abs(days)` as a loop bound and move a whole day, and a `NaN` —
|
|
51
|
+
* the shape a corrupted config or a failed parse takes — failed `remaining > 0` on the first test
|
|
52
|
+
* and returned the input, which reads as "no movement was needed" rather than as a failure.
|
|
47
53
|
*/
|
|
48
54
|
export function addBusinessDays(at: Instant, days: number, calendar: BusinessCalendar): Instant {
|
|
55
|
+
if (!Number.isSafeInteger(days)) {
|
|
56
|
+
throw scheduleInvalid('days', days, 'a whole number of business days');
|
|
57
|
+
}
|
|
49
58
|
if (days === 0) return at;
|
|
50
59
|
const step = days > 0 ? 1 : -1;
|
|
51
60
|
let remaining = Math.abs(days);
|
package/src/context.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { tryUseContext } from '@ultimat3/core';
|
|
8
8
|
import { canonicalTimeZone } from './zone-canonical';
|
|
9
|
-
import { type TimeZone, UTC } from './zones';
|
|
9
|
+
import { assertTimeZone, type TimeZone, UTC } from './zones';
|
|
10
10
|
|
|
11
11
|
/** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
|
|
12
12
|
export const TIMEZONE_HEADER = 'x-timezone';
|
|
@@ -45,8 +45,25 @@ const DEFAULT_ORDER: readonly TimeZoneSourceName[] = ['user', 'cookie', 'query',
|
|
|
45
45
|
|
|
46
46
|
let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The default zone goes through `assertTimeZone`, which both VALIDATES and CANONICALIZES — the two
|
|
50
|
+
* halves `resolveTimeZone` already promises for every other source, on the one source that skipped
|
|
51
|
+
* them. Unchecked, `configureTime({ defaultZone: 'Mars/Olympus' })` was accepted at boot and first
|
|
52
|
+
* refused inside a formatter at render time, from a stack that names no configuration; and
|
|
53
|
+
* `'eUrOpE/bErLiN'` travelled the process as its own zone string, minting a permanent entry in
|
|
54
|
+
* every formatter cache it reached.
|
|
55
|
+
*
|
|
56
|
+
* It throws, where `resolveTimeZone` skips: a stale header from an old client must not fail a
|
|
57
|
+
* request, but a default nothing can fall back to is a boot-time mistake with no second answer.
|
|
58
|
+
*/
|
|
48
59
|
export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
|
|
49
|
-
|
|
60
|
+
const defaultZone =
|
|
61
|
+
partial.defaultZone === undefined ? undefined : assertTimeZone(partial.defaultZone);
|
|
62
|
+
config = {
|
|
63
|
+
...config,
|
|
64
|
+
...partial,
|
|
65
|
+
...(defaultZone === undefined ? {} : { defaultZone }),
|
|
66
|
+
};
|
|
50
67
|
return config;
|
|
51
68
|
}
|
|
52
69
|
|
package/src/cron-occurrence.ts
CHANGED
|
@@ -106,9 +106,12 @@ export function nextCronOccurrence(
|
|
|
106
106
|
carry(cursor);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// The backstop, not the primary check. An impossible day/month pair — `0 0 30 2 *`, a 30th of
|
|
110
|
+
// February — is refused by `parseCron` in constant time, because reaching it here cost ~150ms of
|
|
111
|
+
// blocking CPU per call and `firedSince` pays that per tick of the scheduler's leader loop.
|
|
109
112
|
throw cronInvalid(
|
|
110
113
|
typeof expression === 'string' ? expression : cron.source,
|
|
111
|
-
`no occurrence after ${MAX_STEPS} search steps — the date fields can never all match
|
|
114
|
+
`no occurrence after ${MAX_STEPS} search steps — the date fields can never all match`,
|
|
112
115
|
);
|
|
113
116
|
}
|
|
114
117
|
|
package/src/cron-parse.ts
CHANGED
|
@@ -20,15 +20,20 @@ export interface CronExpression {
|
|
|
20
20
|
dayOfWeekRestricted: boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
// A `Map`, not an object literal: the key is a caller's string, and `MACROS['constructor']` on a
|
|
24
|
+
// plain object answers `Object` — a function that reached `.split()` as a bare `TypeError` out of
|
|
25
|
+
// the one function whose entire contract is a coded refusal.
|
|
26
|
+
const MACROS: ReadonlyMap<string, string> = new Map(
|
|
27
|
+
Object.entries({
|
|
28
|
+
'@yearly': '0 0 1 1 *',
|
|
29
|
+
'@annually': '0 0 1 1 *',
|
|
30
|
+
'@monthly': '0 0 1 * *',
|
|
31
|
+
'@weekly': '0 0 * * 0',
|
|
32
|
+
'@daily': '0 0 * * *',
|
|
33
|
+
'@midnight': '0 0 * * *',
|
|
34
|
+
'@hourly': '0 * * * *',
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
32
37
|
|
|
33
38
|
const MONTH_NAMES = [
|
|
34
39
|
'jan',
|
|
@@ -49,7 +54,7 @@ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
|
49
54
|
/** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
|
|
50
55
|
export function parseCron(expression: string): CronExpression {
|
|
51
56
|
const trimmed = expression.trim().toLowerCase();
|
|
52
|
-
const expanded = MACROS
|
|
57
|
+
const expanded = MACROS.get(trimmed) ?? trimmed;
|
|
53
58
|
const fields = expanded.split(/\s+/).filter((field) => field !== '');
|
|
54
59
|
|
|
55
60
|
if (fields.length !== 5 && fields.length !== 6) {
|
|
@@ -72,7 +77,7 @@ export function parseCron(expression: string): CronExpression {
|
|
|
72
77
|
// 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
|
|
73
78
|
const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
const parsed: CronExpression = {
|
|
76
81
|
source: expanded,
|
|
77
82
|
seconds,
|
|
78
83
|
minutes,
|
|
@@ -83,6 +88,52 @@ export function parseCron(expression: string): CronExpression {
|
|
|
83
88
|
dayOfMonthRestricted: isRestricted(domField ?? '*'),
|
|
84
89
|
dayOfWeekRestricted: isRestricted(dowField ?? '*'),
|
|
85
90
|
};
|
|
91
|
+
assertReachableDate(expression, parsed);
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Longest each month can be. February is 29 because leap years happen — 29 CAN fire, 30 cannot. */
|
|
96
|
+
const LONGEST_MONTH: readonly number[] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
97
|
+
|
|
98
|
+
const MONTH_LABELS = [
|
|
99
|
+
'january',
|
|
100
|
+
'february',
|
|
101
|
+
'march',
|
|
102
|
+
'april',
|
|
103
|
+
'may',
|
|
104
|
+
'june',
|
|
105
|
+
'july',
|
|
106
|
+
'august',
|
|
107
|
+
'september',
|
|
108
|
+
'october',
|
|
109
|
+
'november',
|
|
110
|
+
'december',
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A grammatically valid expression whose day and month fields can never both match — `0 0 30 2 *`,
|
|
115
|
+
* a 30th of February. Decidable from the parsed fields in a bounded 12 x 31 scan, and decided
|
|
116
|
+
* HERE, because the alternative was `nextCronOccurrence` exhausting its 200,000-step walk: ~150 ms
|
|
117
|
+
* of blocking CPU, paid by `firedSince` on every tick of the scheduler's leader loop, to reach a
|
|
118
|
+
* refusal that `isValidCron` had already answered `true` for.
|
|
119
|
+
*
|
|
120
|
+
* Only when day-of-month is restricted and day-of-week is NOT. Vixie's OR rule is why: with both
|
|
121
|
+
* restricted, `0 0 30 2 5` means "the 30th of February OR any Friday in February", which fires
|
|
122
|
+
* every Friday in February — refusing it would break a working schedule.
|
|
123
|
+
*/
|
|
124
|
+
function assertReachableDate(expression: string, cron: CronExpression): void {
|
|
125
|
+
if (!cron.dayOfMonthRestricted || cron.dayOfWeekRestricted) return;
|
|
126
|
+
for (const month of cron.months) {
|
|
127
|
+
const longest = LONGEST_MONTH[month - 1] ?? 31;
|
|
128
|
+
for (const day of cron.daysOfMonth) {
|
|
129
|
+
if (day <= longest) return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const months = cron.months.map((month) => MONTH_LABELS[month - 1] ?? String(month)).join(', ');
|
|
133
|
+
throw cronInvalid(
|
|
134
|
+
expression,
|
|
135
|
+
`day ${cron.daysOfMonth.join(',')} never occurs in ${months}, so this expression can never fire`,
|
|
136
|
+
);
|
|
86
137
|
}
|
|
87
138
|
|
|
88
139
|
export function isValidCron(expression: string): boolean {
|
package/src/duration.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `step.sleep('3d')` in @ultimat3/jobs and every `retry.backoff` value comes through here.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { durationInvalid } from './errors';
|
|
6
|
+
import { durationInvalid, scheduleInvalid } from './errors';
|
|
7
7
|
|
|
8
8
|
export const MS = 1;
|
|
9
9
|
export const SECOND = 1000;
|
|
@@ -65,8 +65,17 @@ export function toMs(duration: string | number): number {
|
|
|
65
65
|
return typeof duration === 'number' ? duration : parseDuration(duration);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* `Math.round` breaks ties toward `+Infinity`, which is asymmetric across zero: `'1500ms'` was 2
|
|
70
|
+
* and `'-1500ms'` was -1, so a signed duration and its mirror did not answer mirrored seconds.
|
|
71
|
+
* The sign is carried out and the MAGNITUDE rounded — `packages/money/src/rounding.ts` is the
|
|
72
|
+
* framework's one statement of this, and `signed()` there is why zero never comes back as `-0`.
|
|
73
|
+
*/
|
|
68
74
|
export function toSeconds(duration: string | number): number {
|
|
69
|
-
|
|
75
|
+
const ms = toMs(duration);
|
|
76
|
+
const seconds = Math.round(Math.abs(ms) / SECOND);
|
|
77
|
+
if (seconds === 0) return 0;
|
|
78
|
+
return ms < 0 ? -seconds : seconds;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
export interface FormatDurationOptions {
|
|
@@ -95,6 +104,14 @@ export function formatDuration(
|
|
|
95
104
|
): string {
|
|
96
105
|
const style = options.style ?? 'short';
|
|
97
106
|
const maxUnits = options.maxUnits ?? 2;
|
|
107
|
+
// Refused, not clamped, for the reason `scheduleInvalid`'s own fix line gives: `maxUnits: 0`
|
|
108
|
+
// made `pieces.length >= maxUnits` true before the first unit was measured, so EVERY duration
|
|
109
|
+
// fell through to the zero fallback and 9,000,000 ms rendered as "0 sec". A caller that asked
|
|
110
|
+
// for no units wanted something this function cannot express, and a silently wrong number on a
|
|
111
|
+
// screen is worse than a failed render.
|
|
112
|
+
if (!Number.isInteger(maxUnits) || maxUnits < 1) {
|
|
113
|
+
throw scheduleInvalid('maxUnits', maxUnits, 'at least 1');
|
|
114
|
+
}
|
|
98
115
|
let remaining = Math.abs(Math.round(ms));
|
|
99
116
|
const pieces: string[] = [];
|
|
100
117
|
|
|
@@ -141,13 +158,25 @@ function parseIso8601Duration(body: string, original: string): number {
|
|
|
141
158
|
const match = ISO_8601.exec(body);
|
|
142
159
|
if (match === null) throw durationInvalid(original);
|
|
143
160
|
const [, weeks, days, hours, minutes, seconds] = match;
|
|
161
|
+
// The guard is on the GROUPS, not on the total. `'P'` and `'PT'` name no duration and must be
|
|
162
|
+
// refused; `'PT0S'` — the canonical zero most emitters write — names one, and testing
|
|
163
|
+
// `total === 0` rejected it along with `'PT0H0M0S'` and `'P0W'` while `'P0D'` was let through
|
|
164
|
+
// by a special case. `ISO_8601` already requires a component group for any body past bare `P`.
|
|
165
|
+
if (
|
|
166
|
+
weeks === undefined &&
|
|
167
|
+
days === undefined &&
|
|
168
|
+
hours === undefined &&
|
|
169
|
+
minutes === undefined &&
|
|
170
|
+
seconds === undefined
|
|
171
|
+
) {
|
|
172
|
+
throw durationInvalid(original);
|
|
173
|
+
}
|
|
144
174
|
const total =
|
|
145
175
|
number(weeks) * WEEK +
|
|
146
176
|
number(days) * DAY +
|
|
147
177
|
number(hours) * HOUR +
|
|
148
178
|
number(minutes) * MINUTE +
|
|
149
179
|
number(seconds) * SECOND;
|
|
150
|
-
if (total === 0 && body.toUpperCase() !== 'P0D') throw durationInvalid(original);
|
|
151
180
|
return Math.round(total);
|
|
152
181
|
}
|
|
153
182
|
|
package/src/format.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
|
|
8
8
|
import { differenceMs, type Instant } from './instant';
|
|
9
|
+
import { isoDateInZone } from './zoned';
|
|
9
10
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
10
11
|
|
|
11
12
|
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
|
@@ -86,15 +87,17 @@ export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): s
|
|
|
86
87
|
return offset === '' ? text : `${text} (${offset})`;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* ISO-8601 date parts in a zone, for `<input type="date">` and CSV columns.
|
|
92
|
+
*
|
|
93
|
+
* Built from `isoDateInZone`, not from `Intl`: `year: 'numeric'` neither zero-pads a year below
|
|
94
|
+
* 1000 nor carries the era, so this answered `'50-01-01'` where `isoDateInZone` answered
|
|
95
|
+
* `'0050-01-01'` — two functions in one package answering one question differently, and the short
|
|
96
|
+
* form matches no ISO pattern and is rejected by the very input this exists for. One padding rule,
|
|
97
|
+
* in one place.
|
|
98
|
+
*/
|
|
90
99
|
export function formatIsoDate(at: Instant, zone: TimeZone): string {
|
|
91
|
-
|
|
92
|
-
timeZone: assertTimeZone(zone),
|
|
93
|
-
year: 'numeric',
|
|
94
|
-
month: '2-digit',
|
|
95
|
-
day: '2-digit',
|
|
96
|
-
}).format(at);
|
|
97
|
-
return parts.replace(/\//g, '-');
|
|
100
|
+
return isoDateInZone(at, assertTimeZone(zone));
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export interface FormatRelativeOptions extends Omit<FormatContext, 'zone'> {
|
|
@@ -155,12 +158,18 @@ const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
|
|
|
155
158
|
};
|
|
156
159
|
|
|
157
160
|
/**
|
|
158
|
-
* `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
+
* `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the ordinal,
|
|
162
|
+
* build it from `Intl.PluralRules` with `type: 'ordinal'` — **English only**, which is why it is a
|
|
163
|
+
* helper and not the default date format.
|
|
164
|
+
*
|
|
165
|
+
* It takes NO locale, and that is the enforcement rather than a note. It used to accept one, pick
|
|
166
|
+
* the plural category with it, and then append the ENGLISH suffix for that category: `ordinal(1,
|
|
167
|
+
* 'de')` was `'1th'`, a word in no language. A parameter that cannot change the answer correctly
|
|
168
|
+
* is removed, so a caller who wants a localized ordinal finds out from `tsc` instead of from a
|
|
169
|
+
* rendered page. **Breaking: the `locale` parameter is gone.**
|
|
161
170
|
*/
|
|
162
|
-
export function ordinal(value: number
|
|
163
|
-
const category = new Intl.PluralRules(
|
|
171
|
+
export function ordinal(value: number): string {
|
|
172
|
+
const category = new Intl.PluralRules('en', { type: 'ordinal' }).select(value);
|
|
164
173
|
return `${value}${ORDINAL_SUFFIX[category]}`;
|
|
165
174
|
}
|
|
166
175
|
|
package/src/instant.ts
CHANGED
|
@@ -24,8 +24,20 @@ export function instant(value: Date): Instant {
|
|
|
24
24
|
return new Date(value.getTime()) as Instant;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* A time of day, and the zone it is stated in. `2026-03-14T09:00` without one is resolved by
|
|
29
|
+
* `new Date` through the PROCESS's zone, so the guard has to see both halves: a string carrying
|
|
30
|
+
* a clock time is refused unless it also carries `Z` or an offset. A date-only form carries no
|
|
31
|
+
* clock time and is UTC by specification, so it passes.
|
|
32
|
+
*/
|
|
33
|
+
const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
|
|
34
|
+
const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
35
|
+
|
|
36
|
+
/** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is refused. */
|
|
28
37
|
export function fromIso(iso: string): Instant {
|
|
38
|
+
// Enforced, not documented: this header asked for an offset for three releases while the body
|
|
39
|
+
// accepted a bare local string and answered a different instant per deployment timezone.
|
|
40
|
+
if (CLOCK_TIME.test(iso) && !UTC_OFFSET.test(iso)) throw instantInvalid(iso);
|
|
29
41
|
const parsed = new Date(iso);
|
|
30
42
|
if (Number.isNaN(parsed.getTime())) throw instantInvalid(iso);
|
|
31
43
|
return parsed as Instant;
|
|
@@ -41,9 +53,17 @@ export function toIsoDateUtc(at: Instant): string {
|
|
|
41
53
|
return at.toISOString().slice(0, 10);
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The SAME test `instant()` performs, and for the same reason. `Number.isFinite` is not the `Date`
|
|
58
|
+
* range: `+/-8.64e15` ms is the limit, so a finite `1e16` produced an Invalid Date branded as an
|
|
59
|
+
* `Instant` — a value `isInstant` answers `false` for (the type's own predicate rejecting what its
|
|
60
|
+
* constructor certified) and `toIso` throws a bare `RangeError` out of. Reached through
|
|
61
|
+
* `fromEpochSeconds`, `addMs`, `subtractMs` and `now(clock)`, none of which re-check.
|
|
62
|
+
*/
|
|
44
63
|
export function fromEpochMs(ms: number): Instant {
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
const at = new Date(ms);
|
|
65
|
+
if (Number.isNaN(at.getTime())) throw instantInvalid(String(ms));
|
|
66
|
+
return at as Instant;
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
export function toEpochMs(at: Instant): number {
|
|
@@ -105,7 +125,11 @@ export function epoch(): Instant {
|
|
|
105
125
|
return new Date(0) as Instant;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* `Clock.now()` returns a `Date`, so the number branch is unreachable through the typed API. It is
|
|
130
|
+
* kept for the untyped caller: a JS `Clock` answering epoch milliseconds would otherwise reach
|
|
131
|
+
* `.getTime()` on a number and throw a bare `TypeError` instead of `X_INSTANT_INVALID`.
|
|
132
|
+
*/
|
|
109
133
|
function epochMsOf(value: Date | number): number {
|
|
110
134
|
return typeof value === 'number' ? value : value.getTime();
|
|
111
135
|
}
|