@c9up/chronos 0.1.3
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/LICENSE +21 -0
- package/README.md +28 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +57 -0
- package/scripts/copy-napi.mjs +48 -0
- package/scripts/verify-napi.mjs +46 -0
- package/src/DateTime.ts +641 -0
- package/src/Duration.ts +368 -0
- package/src/Interval.ts +227 -0
- package/src/atlas.ts +160 -0
- package/src/index.ts +75 -0
- package/src/native.ts +124 -0
- package/src/rrule.ts +102 -0
- package/src/utils.ts +12 -0
- package/wasm/chronos_engine_wasm.d.ts +62 -0
package/src/Duration.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration — immutable time-span value object for the Ream framework.
|
|
3
|
+
*
|
|
4
|
+
* Represents a length of time as a bag of calendar + clock units. Closely
|
|
5
|
+
* mirrors Luxon's `Duration` API so developers coming from AdonisJS feel at
|
|
6
|
+
* home. Pure TypeScript — no Rust/NAPI dependency (durations are lightweight
|
|
7
|
+
* arithmetic on small integers, not worth an FFI crossing).
|
|
8
|
+
*
|
|
9
|
+
* @implements Story 36.8
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DurationObject {
|
|
13
|
+
years?: number;
|
|
14
|
+
months?: number;
|
|
15
|
+
weeks?: number;
|
|
16
|
+
days?: number;
|
|
17
|
+
hours?: number;
|
|
18
|
+
minutes?: number;
|
|
19
|
+
seconds?: number;
|
|
20
|
+
milliseconds?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DurationUnit = keyof DurationObject;
|
|
24
|
+
|
|
25
|
+
const ORDERED_UNITS: DurationUnit[] = [
|
|
26
|
+
"years",
|
|
27
|
+
"months",
|
|
28
|
+
"weeks",
|
|
29
|
+
"days",
|
|
30
|
+
"hours",
|
|
31
|
+
"minutes",
|
|
32
|
+
"seconds",
|
|
33
|
+
"milliseconds",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Millisecond equivalents for clock units. Calendar units (years/months) are
|
|
37
|
+
* not convertible to a fixed ms count — they depend on the anchor date. When
|
|
38
|
+
* converting, we use an approximation (30-day month, 365-day year) and
|
|
39
|
+
* document it. Luxon does the same. */
|
|
40
|
+
const MS_PER: Record<DurationUnit, number> = {
|
|
41
|
+
years: 365.25 * 24 * 60 * 60 * 1000,
|
|
42
|
+
months: 30 * 24 * 60 * 60 * 1000,
|
|
43
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
44
|
+
days: 24 * 60 * 60 * 1000,
|
|
45
|
+
hours: 60 * 60 * 1000,
|
|
46
|
+
minutes: 60 * 1000,
|
|
47
|
+
seconds: 1000,
|
|
48
|
+
milliseconds: 1,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export class Duration {
|
|
52
|
+
readonly #values: Readonly<Required<DurationObject>>;
|
|
53
|
+
|
|
54
|
+
private constructor(values: DurationObject) {
|
|
55
|
+
this.#values = {
|
|
56
|
+
years: values.years ?? 0,
|
|
57
|
+
months: values.months ?? 0,
|
|
58
|
+
weeks: values.weeks ?? 0,
|
|
59
|
+
days: values.days ?? 0,
|
|
60
|
+
hours: values.hours ?? 0,
|
|
61
|
+
minutes: values.minutes ?? 0,
|
|
62
|
+
seconds: values.seconds ?? 0,
|
|
63
|
+
milliseconds: values.milliseconds ?? 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Factories ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Build from a plain object of unit values. */
|
|
70
|
+
static fromObject(obj: DurationObject): Duration {
|
|
71
|
+
return new Duration(obj);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build from a total number of milliseconds (approximates calendar units). */
|
|
75
|
+
static fromMillis(ms: number): Duration {
|
|
76
|
+
return new Duration({ milliseconds: ms });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an ISO 8601 duration string (`P1Y2M3DT4H5M6.789S`).
|
|
81
|
+
*
|
|
82
|
+
* Supports the full `PnYnMnDTnHnMnS` form. Fractional seconds are
|
|
83
|
+
* rounded to the nearest millisecond. Weeks (`PnW`) are a separate form
|
|
84
|
+
* that cannot be mixed with other designators per the spec.
|
|
85
|
+
*/
|
|
86
|
+
static fromISO(iso: string): Duration {
|
|
87
|
+
const s = iso.trim();
|
|
88
|
+
if (!s.startsWith("P"))
|
|
89
|
+
throw new Error(`Invalid ISO 8601 duration: ${iso}`);
|
|
90
|
+
|
|
91
|
+
// Week form: P3W
|
|
92
|
+
const weekMatch = /^P(\d+(?:\.\d+)?)W$/.exec(s);
|
|
93
|
+
if (weekMatch) {
|
|
94
|
+
return new Duration({ weeks: Number.parseFloat(weekMatch[1]) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const match =
|
|
98
|
+
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(
|
|
99
|
+
s,
|
|
100
|
+
);
|
|
101
|
+
if (!match) throw new Error(`Invalid ISO 8601 duration: ${iso}`);
|
|
102
|
+
|
|
103
|
+
const [, y, mo, d, h, mi, sec] = match;
|
|
104
|
+
const seconds = sec ? Math.floor(Number.parseFloat(sec)) : 0;
|
|
105
|
+
const milliseconds = sec
|
|
106
|
+
? Math.round((Number.parseFloat(sec) - seconds) * 1000)
|
|
107
|
+
: 0;
|
|
108
|
+
return new Duration({
|
|
109
|
+
years: y ? Number(y) : 0,
|
|
110
|
+
months: mo ? Number(mo) : 0,
|
|
111
|
+
days: d ? Number(d) : 0,
|
|
112
|
+
hours: h ? Number(h) : 0,
|
|
113
|
+
minutes: mi ? Number(mi) : 0,
|
|
114
|
+
seconds,
|
|
115
|
+
milliseconds,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Accessors ──────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
get years(): number {
|
|
122
|
+
return this.#values.years;
|
|
123
|
+
}
|
|
124
|
+
get months(): number {
|
|
125
|
+
return this.#values.months;
|
|
126
|
+
}
|
|
127
|
+
get weeks(): number {
|
|
128
|
+
return this.#values.weeks;
|
|
129
|
+
}
|
|
130
|
+
get days(): number {
|
|
131
|
+
return this.#values.days;
|
|
132
|
+
}
|
|
133
|
+
get hours(): number {
|
|
134
|
+
return this.#values.hours;
|
|
135
|
+
}
|
|
136
|
+
get minutes(): number {
|
|
137
|
+
return this.#values.minutes;
|
|
138
|
+
}
|
|
139
|
+
get seconds(): number {
|
|
140
|
+
return this.#values.seconds;
|
|
141
|
+
}
|
|
142
|
+
get milliseconds(): number {
|
|
143
|
+
return this.#values.milliseconds;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Get a specific unit. */
|
|
147
|
+
get(unit: DurationUnit): number {
|
|
148
|
+
return this.#values[unit];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Return the internal bag as a plain object. */
|
|
152
|
+
toObject(): Required<DurationObject> {
|
|
153
|
+
return { ...this.#values };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Arithmetic ─────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Add another duration to this one (per-unit addition). */
|
|
159
|
+
plus(other: Duration | DurationObject): Duration {
|
|
160
|
+
const o = other instanceof Duration ? other.#values : other;
|
|
161
|
+
return new Duration({
|
|
162
|
+
years: this.#values.years + (o.years ?? 0),
|
|
163
|
+
months: this.#values.months + (o.months ?? 0),
|
|
164
|
+
weeks: this.#values.weeks + (o.weeks ?? 0),
|
|
165
|
+
days: this.#values.days + (o.days ?? 0),
|
|
166
|
+
hours: this.#values.hours + (o.hours ?? 0),
|
|
167
|
+
minutes: this.#values.minutes + (o.minutes ?? 0),
|
|
168
|
+
seconds: this.#values.seconds + (o.seconds ?? 0),
|
|
169
|
+
milliseconds: this.#values.milliseconds + (o.milliseconds ?? 0),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Subtract another duration from this one. */
|
|
174
|
+
minus(other: Duration | DurationObject): Duration {
|
|
175
|
+
const o = other instanceof Duration ? other.#values : other;
|
|
176
|
+
return new Duration({
|
|
177
|
+
years: this.#values.years - (o.years ?? 0),
|
|
178
|
+
months: this.#values.months - (o.months ?? 0),
|
|
179
|
+
weeks: this.#values.weeks - (o.weeks ?? 0),
|
|
180
|
+
days: this.#values.days - (o.days ?? 0),
|
|
181
|
+
hours: this.#values.hours - (o.hours ?? 0),
|
|
182
|
+
minutes: this.#values.minutes - (o.minutes ?? 0),
|
|
183
|
+
seconds: this.#values.seconds - (o.seconds ?? 0),
|
|
184
|
+
milliseconds: this.#values.milliseconds - (o.milliseconds ?? 0),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Flip the sign on every unit. */
|
|
189
|
+
negate(): Duration {
|
|
190
|
+
return new Duration({
|
|
191
|
+
years: -this.#values.years,
|
|
192
|
+
months: -this.#values.months,
|
|
193
|
+
weeks: -this.#values.weeks,
|
|
194
|
+
days: -this.#values.days,
|
|
195
|
+
hours: -this.#values.hours,
|
|
196
|
+
minutes: -this.#values.minutes,
|
|
197
|
+
seconds: -this.#values.seconds,
|
|
198
|
+
milliseconds: -this.#values.milliseconds,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Conversion ─────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Normalize clock units — cascade overflows upward so that e.g. 90 seconds
|
|
206
|
+
* becomes 1 minute 30 seconds. Calendar units (years, months) are left
|
|
207
|
+
* untouched because normalizing months→years requires knowing *which* year.
|
|
208
|
+
*
|
|
209
|
+
* Weeks are not cascaded into months — they stay as weeks unless the source
|
|
210
|
+
* explicitly had weeks. This matches Luxon's behavior.
|
|
211
|
+
*/
|
|
212
|
+
normalize(): Duration {
|
|
213
|
+
let ms = this.#values.milliseconds;
|
|
214
|
+
let sec = this.#values.seconds;
|
|
215
|
+
let min = this.#values.minutes;
|
|
216
|
+
let hrs = this.#values.hours;
|
|
217
|
+
let days = this.#values.days;
|
|
218
|
+
|
|
219
|
+
// Use a carry function that handles negative values correctly.
|
|
220
|
+
// JS `%` is remainder (preserves sign), not modulo. For negative
|
|
221
|
+
// durations we need true modulo so the remainder is always non-negative
|
|
222
|
+
// relative to its parent unit, matching the mathematical convention.
|
|
223
|
+
const carry = (value: number, divisor: number): [number, number] => {
|
|
224
|
+
const quot = Math.trunc(value / divisor);
|
|
225
|
+
const rem = value - quot * divisor;
|
|
226
|
+
return [quot, rem];
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let c: number;
|
|
230
|
+
[c, ms] = carry(ms, 1000);
|
|
231
|
+
sec += c;
|
|
232
|
+
[c, sec] = carry(sec, 60);
|
|
233
|
+
min += c;
|
|
234
|
+
[c, min] = carry(min, 60);
|
|
235
|
+
hrs += c;
|
|
236
|
+
[c, hrs] = carry(hrs, 24);
|
|
237
|
+
days += c;
|
|
238
|
+
|
|
239
|
+
return new Duration({
|
|
240
|
+
years: this.#values.years,
|
|
241
|
+
months: this.#values.months,
|
|
242
|
+
weeks: this.#values.weeks,
|
|
243
|
+
days,
|
|
244
|
+
hours: hrs,
|
|
245
|
+
minutes: min,
|
|
246
|
+
seconds: sec,
|
|
247
|
+
milliseconds: ms,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Re-project this duration into the given units. Uses approximate
|
|
253
|
+
* conversions for calendar units (365.25d/year, 30d/month). The result
|
|
254
|
+
* is expressed only in the requested units; all others are zero.
|
|
255
|
+
*
|
|
256
|
+
* Duration.fromObject({ hours: 25 }).shiftTo('days', 'hours')
|
|
257
|
+
* // → { days: 1, hours: 1 }
|
|
258
|
+
*/
|
|
259
|
+
shiftTo(...units: DurationUnit[]): Duration {
|
|
260
|
+
if (units.length === 0) return this;
|
|
261
|
+
|
|
262
|
+
// Convert the entire duration to approximate milliseconds, then greedily
|
|
263
|
+
// distribute into the requested units from largest to smallest.
|
|
264
|
+
let totalMs = 0;
|
|
265
|
+
for (const unit of ORDERED_UNITS) {
|
|
266
|
+
totalMs += this.#values[unit] * MS_PER[unit];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sorted = [...units].sort(
|
|
270
|
+
(a, b) => ORDERED_UNITS.indexOf(a) - ORDERED_UNITS.indexOf(b),
|
|
271
|
+
);
|
|
272
|
+
const result: DurationObject = {};
|
|
273
|
+
for (const unit of sorted) {
|
|
274
|
+
const value = Math.trunc(totalMs / MS_PER[unit]);
|
|
275
|
+
totalMs -= value * MS_PER[unit];
|
|
276
|
+
result[unit] = value;
|
|
277
|
+
}
|
|
278
|
+
// Any leftover ms goes into the smallest requested unit as a fractional part.
|
|
279
|
+
if (totalMs !== 0 && sorted.length > 0) {
|
|
280
|
+
const smallest = sorted[sorted.length - 1];
|
|
281
|
+
result[smallest] = (result[smallest] ?? 0) + totalMs / MS_PER[smallest];
|
|
282
|
+
}
|
|
283
|
+
return new Duration(result);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Express the total duration in a single unit (approximate for calendar
|
|
288
|
+
* units — same caveat as `shiftTo`).
|
|
289
|
+
*/
|
|
290
|
+
as(unit: DurationUnit): number {
|
|
291
|
+
let totalMs = 0;
|
|
292
|
+
for (const u of ORDERED_UNITS) {
|
|
293
|
+
totalMs += this.#values[u] * MS_PER[u];
|
|
294
|
+
}
|
|
295
|
+
return totalMs / MS_PER[unit];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Formatting ─────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/** ISO 8601 duration string (`P1Y2M3DT4H5M6S`). */
|
|
301
|
+
toISO(): string {
|
|
302
|
+
const v = this.#values;
|
|
303
|
+
let date = "";
|
|
304
|
+
if (v.years) date += `${v.years}Y`;
|
|
305
|
+
if (v.months) date += `${v.months}M`;
|
|
306
|
+
if (v.weeks) date += `${v.weeks}W`;
|
|
307
|
+
if (v.days) date += `${v.days}D`;
|
|
308
|
+
let time = "";
|
|
309
|
+
if (v.hours) time += `${v.hours}H`;
|
|
310
|
+
if (v.minutes) time += `${v.minutes}M`;
|
|
311
|
+
const totalSec = v.seconds + v.milliseconds / 1000;
|
|
312
|
+
if (totalSec) time += `${totalSec}S`;
|
|
313
|
+
if (!date && !time) return "PT0S";
|
|
314
|
+
return `P${date}${time ? `T${time}` : ""}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Simple pattern format: `hh:mm:ss`, `HH:mm`, etc. Supported tokens:
|
|
319
|
+
* `Y` (years), `M` (months), `d` (days), `h`/`hh` (hours), `m`/`mm`
|
|
320
|
+
* (minutes), `s`/`ss` (seconds), `S`/`SSS` (milliseconds).
|
|
321
|
+
*/
|
|
322
|
+
toFormat(pattern: string): string {
|
|
323
|
+
const v = this.normalize().#values;
|
|
324
|
+
return pattern
|
|
325
|
+
.replace(/hh/g, String(v.hours).padStart(2, "0"))
|
|
326
|
+
.replace(/mm/g, String(v.minutes).padStart(2, "0"))
|
|
327
|
+
.replace(/ss/g, String(v.seconds).padStart(2, "0"))
|
|
328
|
+
.replace(/SSS/g, String(v.milliseconds).padStart(3, "0"))
|
|
329
|
+
.replace(/h/g, String(v.hours))
|
|
330
|
+
.replace(/m/g, String(v.minutes))
|
|
331
|
+
.replace(/s/g, String(v.seconds))
|
|
332
|
+
.replace(/S/g, String(v.milliseconds))
|
|
333
|
+
.replace(/Y/g, String(v.years))
|
|
334
|
+
.replace(/M/g, String(v.months))
|
|
335
|
+
.replace(/d/g, String(v.days));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Human-readable form: "2 hours, 15 minutes". Uses English hardcoded —
|
|
340
|
+
* locale-aware formatting will go through Rosetta once Story 36.12 lands.
|
|
341
|
+
*/
|
|
342
|
+
toHuman(): string {
|
|
343
|
+
const v = this.normalize().#values;
|
|
344
|
+
const parts: string[] = [];
|
|
345
|
+
if (v.years) parts.push(`${v.years} ${v.years === 1 ? "year" : "years"}`);
|
|
346
|
+
if (v.months)
|
|
347
|
+
parts.push(`${v.months} ${v.months === 1 ? "month" : "months"}`);
|
|
348
|
+
if (v.weeks) parts.push(`${v.weeks} ${v.weeks === 1 ? "week" : "weeks"}`);
|
|
349
|
+
if (v.days) parts.push(`${v.days} ${v.days === 1 ? "day" : "days"}`);
|
|
350
|
+
if (v.hours) parts.push(`${v.hours} ${v.hours === 1 ? "hour" : "hours"}`);
|
|
351
|
+
if (v.minutes)
|
|
352
|
+
parts.push(`${v.minutes} ${v.minutes === 1 ? "minute" : "minutes"}`);
|
|
353
|
+
if (v.seconds)
|
|
354
|
+
parts.push(`${v.seconds} ${v.seconds === 1 ? "second" : "seconds"}`);
|
|
355
|
+
if (v.milliseconds)
|
|
356
|
+
parts.push(
|
|
357
|
+
`${v.milliseconds} ${v.milliseconds === 1 ? "millisecond" : "milliseconds"}`,
|
|
358
|
+
);
|
|
359
|
+
return parts.length > 0 ? parts.join(", ") : "0 seconds";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
toString(): string {
|
|
363
|
+
return this.toISO();
|
|
364
|
+
}
|
|
365
|
+
toJSON(): string {
|
|
366
|
+
return this.toISO();
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/Interval.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interval — immutable half-open `[start, end)` time range.
|
|
3
|
+
*
|
|
4
|
+
* Provides set-style operations (`contains`, `overlaps`, `union`,
|
|
5
|
+
* `intersection`, `splitBy`, `splitAt`) that the standalone range helpers
|
|
6
|
+
* in `DateTime.ts` could not express cleanly as methods.
|
|
7
|
+
*
|
|
8
|
+
* @implements Story 36.9
|
|
9
|
+
*/
|
|
10
|
+
import { type DateInput, DateTime, type DateUnit } from "./DateTime.js";
|
|
11
|
+
import { Duration, type DurationUnit } from "./Duration.js";
|
|
12
|
+
|
|
13
|
+
export class Interval {
|
|
14
|
+
readonly #start: DateTime;
|
|
15
|
+
readonly #end: DateTime;
|
|
16
|
+
|
|
17
|
+
private constructor(start: DateTime, end: DateTime) {
|
|
18
|
+
if (start.isAfter(end)) {
|
|
19
|
+
throw new Error("Interval start must be <= end");
|
|
20
|
+
}
|
|
21
|
+
this.#start = start;
|
|
22
|
+
this.#end = end;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Factories ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Build from two `DateInput` values. Half-open: `[start, end)`. */
|
|
28
|
+
static fromDateTimes(start: DateInput, end: DateInput): Interval {
|
|
29
|
+
return new Interval(DateTime.from(start), DateTime.from(end));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build from a start + duration forward. */
|
|
33
|
+
static after(
|
|
34
|
+
start: DateInput,
|
|
35
|
+
duration: Duration | { amount: number; unit: DateUnit },
|
|
36
|
+
): Interval {
|
|
37
|
+
const s = DateTime.from(start);
|
|
38
|
+
// For Duration objects, convert to total milliseconds and add as ms —
|
|
39
|
+
// NOT as seconds (which was the original bug: Duration.fromMillis(1500)
|
|
40
|
+
// was being treated as 1500 seconds instead of 1.5 seconds).
|
|
41
|
+
const e =
|
|
42
|
+
"amount" in duration
|
|
43
|
+
? s.plus(duration.amount, duration.unit as DateUnit)
|
|
44
|
+
: DateTime.fromMillis(
|
|
45
|
+
s.toMillis() + Math.round(duration.as("milliseconds")),
|
|
46
|
+
);
|
|
47
|
+
return new Interval(s, e);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Accessors ──────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
get start(): DateTime {
|
|
53
|
+
return this.#start;
|
|
54
|
+
}
|
|
55
|
+
get end(): DateTime {
|
|
56
|
+
return this.#end;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Length in the given unit (approximate for calendar units). */
|
|
60
|
+
length(unit: DurationUnit = "milliseconds"): number {
|
|
61
|
+
const ms = this.#end.toMillis() - this.#start.toMillis();
|
|
62
|
+
const MS_PER: Record<string, number> = {
|
|
63
|
+
years: 365.25 * 86400000,
|
|
64
|
+
months: 30 * 86400000,
|
|
65
|
+
weeks: 7 * 86400000,
|
|
66
|
+
days: 86400000,
|
|
67
|
+
hours: 3600000,
|
|
68
|
+
minutes: 60000,
|
|
69
|
+
seconds: 1000,
|
|
70
|
+
milliseconds: 1,
|
|
71
|
+
};
|
|
72
|
+
return ms / (MS_PER[unit] ?? 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Duration object between start and end. */
|
|
76
|
+
toDuration(): Duration {
|
|
77
|
+
return Duration.fromMillis(this.#end.toMillis() - this.#start.toMillis());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Containment ────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Does this interval contain the given instant? Half-open: `[start, end)`. */
|
|
83
|
+
contains(dt: DateInput): boolean {
|
|
84
|
+
const t = DateTime.from(dt).toMillis();
|
|
85
|
+
return t >= this.#start.toMillis() && t < this.#end.toMillis();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Is the given instant strictly before this interval? */
|
|
89
|
+
isBefore(dt: DateInput): boolean {
|
|
90
|
+
return DateTime.from(dt).toMillis() >= this.#end.toMillis();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is the given instant strictly after this interval? */
|
|
94
|
+
isAfter(dt: DateInput): boolean {
|
|
95
|
+
return DateTime.from(dt).toMillis() < this.#start.toMillis();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Is this interval empty (zero length)? */
|
|
99
|
+
isEmpty(): boolean {
|
|
100
|
+
return this.#start.toMillis() === this.#end.toMillis();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Set operations ─────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Do the two intervals share any time? */
|
|
106
|
+
overlaps(other: Interval): boolean {
|
|
107
|
+
return (
|
|
108
|
+
this.#start.toMillis() < other.#end.toMillis() &&
|
|
109
|
+
other.#start.toMillis() < this.#end.toMillis()
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Does this interval fully enclose `other`? */
|
|
114
|
+
engulfs(other: Interval): boolean {
|
|
115
|
+
return (
|
|
116
|
+
this.#start.toMillis() <= other.#start.toMillis() &&
|
|
117
|
+
this.#end.toMillis() >= other.#end.toMillis()
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Does `other`'s start touch this interval's end (no overlap, no gap)? */
|
|
122
|
+
abutsStart(other: Interval): boolean {
|
|
123
|
+
return this.#end.toMillis() === other.#start.toMillis();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Does `other`'s end touch this interval's start? */
|
|
127
|
+
abutsEnd(other: Interval): boolean {
|
|
128
|
+
return other.#end.toMillis() === this.#start.toMillis();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The overlapping sub-interval, or `null` if disjoint. */
|
|
132
|
+
intersection(other: Interval): Interval | null {
|
|
133
|
+
const s = Math.max(this.#start.toMillis(), other.#start.toMillis());
|
|
134
|
+
const e = Math.min(this.#end.toMillis(), other.#end.toMillis());
|
|
135
|
+
if (s >= e) return null;
|
|
136
|
+
return Interval.fromDateTimes(
|
|
137
|
+
DateTime.fromMillis(s),
|
|
138
|
+
DateTime.fromMillis(e),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The smallest interval that covers both, or `null` if they don't overlap or abut. */
|
|
143
|
+
union(other: Interval): Interval | null {
|
|
144
|
+
if (
|
|
145
|
+
!this.overlaps(other) &&
|
|
146
|
+
!this.abutsStart(other) &&
|
|
147
|
+
!this.abutsEnd(other)
|
|
148
|
+
) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return Interval.fromDateTimes(
|
|
152
|
+
DateTime.fromMillis(
|
|
153
|
+
Math.min(this.#start.toMillis(), other.#start.toMillis()),
|
|
154
|
+
),
|
|
155
|
+
DateTime.fromMillis(
|
|
156
|
+
Math.max(this.#end.toMillis(), other.#end.toMillis()),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Split ──────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Split this interval into N sub-intervals of roughly equal `duration` length. */
|
|
164
|
+
splitBy(duration: Duration | { amount: number; unit: DateUnit }): Interval[] {
|
|
165
|
+
const unitMs: Record<string, number> = {
|
|
166
|
+
year: 365.25 * 86400000,
|
|
167
|
+
month: 30 * 86400000,
|
|
168
|
+
week: 7 * 86400000,
|
|
169
|
+
day: 86400000,
|
|
170
|
+
hour: 3600000,
|
|
171
|
+
minute: 60000,
|
|
172
|
+
second: 1000,
|
|
173
|
+
};
|
|
174
|
+
const stepMs =
|
|
175
|
+
"amount" in duration
|
|
176
|
+
? duration.amount * (unitMs[duration.unit] ?? 1)
|
|
177
|
+
: duration.as("milliseconds");
|
|
178
|
+
if (stepMs <= 0) throw new Error("splitBy duration must be positive");
|
|
179
|
+
|
|
180
|
+
const result: Interval[] = [];
|
|
181
|
+
let cursor = this.#start.toMillis();
|
|
182
|
+
const endMs = this.#end.toMillis();
|
|
183
|
+
while (cursor < endMs) {
|
|
184
|
+
const next = Math.min(cursor + stepMs, endMs);
|
|
185
|
+
result.push(
|
|
186
|
+
Interval.fromDateTimes(
|
|
187
|
+
DateTime.fromMillis(cursor),
|
|
188
|
+
DateTime.fromMillis(next),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
cursor = next;
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Split at specific instants. Instants outside the interval are ignored. */
|
|
197
|
+
splitAt(...dts: DateInput[]): Interval[] {
|
|
198
|
+
const points = dts
|
|
199
|
+
.map((d) => DateTime.from(d).toMillis())
|
|
200
|
+
.filter((ms) => ms > this.#start.toMillis() && ms < this.#end.toMillis())
|
|
201
|
+
.sort((a, b) => a - b);
|
|
202
|
+
|
|
203
|
+
const result: Interval[] = [];
|
|
204
|
+
let prev = this.#start.toMillis();
|
|
205
|
+
for (const p of points) {
|
|
206
|
+
result.push(
|
|
207
|
+
Interval.fromDateTimes(
|
|
208
|
+
DateTime.fromMillis(prev),
|
|
209
|
+
DateTime.fromMillis(p),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
prev = p;
|
|
213
|
+
}
|
|
214
|
+
result.push(Interval.fromDateTimes(DateTime.fromMillis(prev), this.#end));
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Serialization ──────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
toString(): string {
|
|
221
|
+
return `[${this.#start.toISO()}, ${this.#end.toISO()})`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
toJSON(): { start: string; end: string } {
|
|
225
|
+
return { start: this.#start.toISO(), end: this.#end.toISO() };
|
|
226
|
+
}
|
|
227
|
+
}
|