native-dates 0.0.0-stage → 0.0.6
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 +7 -0
- package/README.md +41 -2
- package/package.json +46 -4
- package/src/calendar-date.js +393 -0
- package/src/date-time.js +433 -0
- package/src/duration.js +341 -0
- package/src/helpers/compare.js +126 -0
- package/src/helpers/errors.js +89 -0
- package/src/helpers/fields.js +194 -0
- package/src/helpers/format.js +93 -0
- package/src/helpers/identity.js +10 -0
- package/src/helpers/intl.js +45 -0
- package/src/helpers/types.js +20 -0
- package/src/helpers/units.js +147 -0
- package/src/helpers/zones.js +113 -0
- package/src/index.js +30 -0
- package/src/time.js +357 -0
- package/types/index.d.ts +538 -0
package/src/date-time.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { CalendarDate, today } from './calendar-date.js';
|
|
2
|
+
import { anchored } from './duration.js';
|
|
3
|
+
import { guard, loosely, parseLoose, refuse, refuseType, requireLoose } from './helpers/errors.js';
|
|
4
|
+
import { temporalDurationFrom } from './helpers/fields.js';
|
|
5
|
+
import { formatIntl, intlOptions, unknownFormat } from './helpers/format.js';
|
|
6
|
+
import { IS_DATE_TIME } from './helpers/identity.js';
|
|
7
|
+
import { isDate, isDevelopment, isNumber, isPlainObject, isString } from './helpers/types.js';
|
|
8
|
+
import {
|
|
9
|
+
inspect,
|
|
10
|
+
isDigits,
|
|
11
|
+
isInstant,
|
|
12
|
+
isPlainDate,
|
|
13
|
+
isPlainDateTime,
|
|
14
|
+
isZonedDateTime,
|
|
15
|
+
quarterStart,
|
|
16
|
+
singularKeys,
|
|
17
|
+
stepOf,
|
|
18
|
+
unit,
|
|
19
|
+
weekdayNumber,
|
|
20
|
+
withParts,
|
|
21
|
+
zoneOptions,
|
|
22
|
+
} from './helpers/units.js';
|
|
23
|
+
import { weekStart, zoneId } from './helpers/zones.js';
|
|
24
|
+
import { Time } from './time.js';
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
an exact moment with a zone to read it in. one type for timestamps, appointments and deadlines.
|
|
28
|
+
an explicit zone wins, then a bracketed zone in the string, then the default. a Z or an offset in
|
|
29
|
+
the string fixes the instant only, it never becomes the zone: the offset a database prints is a
|
|
30
|
+
view, and arithmetic across a DST boundary needs the real zone
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const hasTime = /[T ]\d{1,2}(?::?\d{2})?/;
|
|
34
|
+
const hasOffset = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/;
|
|
35
|
+
|
|
36
|
+
export class DateTime {
|
|
37
|
+
// brand datetime
|
|
38
|
+
get [IS_DATE_TIME]() {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
static [Symbol.hasInstance](value) {
|
|
42
|
+
return !!value?.[IS_DATE_TIME];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#zoned;
|
|
46
|
+
|
|
47
|
+
constructor(input, options) {
|
|
48
|
+
const settings = zoneOptions(options);
|
|
49
|
+
this.#zoned =
|
|
50
|
+
isZonedDateTime(input) && settings.zone === undefined && !settings.loose && settings.dayFirst === undefined
|
|
51
|
+
? input
|
|
52
|
+
: DateTime.#read(input, settings);
|
|
53
|
+
// the parts are own properties, so a value prints them in a console without a click and reads them without a call,
|
|
54
|
+
// in the order a preview reads: the text first, filled once the parts it reads are in, then month, day, year
|
|
55
|
+
this.text = undefined;
|
|
56
|
+
const zoned = this.#zoned;
|
|
57
|
+
this.month = zoned.month;
|
|
58
|
+
this.day = zoned.day;
|
|
59
|
+
this.year = zoned.year;
|
|
60
|
+
this.weekday = zoned.dayOfWeek;
|
|
61
|
+
this.hour = zoned.hour;
|
|
62
|
+
this.minute = zoned.minute;
|
|
63
|
+
this.second = zoned.second;
|
|
64
|
+
this.millisecond = zoned.millisecond;
|
|
65
|
+
this.zone = zoned.timeZoneId;
|
|
66
|
+
this.offset = zoned.offset;
|
|
67
|
+
this.epoch = zoned.epochMilliseconds;
|
|
68
|
+
this.text = this.format('text');
|
|
69
|
+
Object.freeze(this);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static #read(input, { zone, loose, dayFirst } = {}) {
|
|
73
|
+
// the zone resolves first so a bad one throws even when the input turns out unreadable
|
|
74
|
+
const timeZone = zoneId(zone);
|
|
75
|
+
if (loose || dayFirst !== undefined) {
|
|
76
|
+
requireLoose(loose ? 'loose' : 'dayFirst');
|
|
77
|
+
}
|
|
78
|
+
if (input === undefined || input === null) {
|
|
79
|
+
return refuseType('notADateTime', String(input), {
|
|
80
|
+
explanation: isDevelopment
|
|
81
|
+
? 'now() is the current moment. a missing value is refused so an absent field never silently becomes now'
|
|
82
|
+
: 0,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (input instanceof DateTime) {
|
|
86
|
+
return zone === undefined ? input.toTemporal() : input.toTemporal().withTimeZone(timeZone);
|
|
87
|
+
}
|
|
88
|
+
if (input instanceof CalendarDate) {
|
|
89
|
+
return input.toTemporal().toZonedDateTime({ timeZone });
|
|
90
|
+
}
|
|
91
|
+
if (isDate(input) || isNumber(input)) {
|
|
92
|
+
const epoch = isNumber(input) ? input : input.getTime();
|
|
93
|
+
if (!Number.isFinite(epoch)) {
|
|
94
|
+
refuse('notFinite', String(input), {
|
|
95
|
+
explanation: isDevelopment ? 'an invalid Date or a non-finite number has no moment to point at' : 0,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return Temporal.Instant.fromEpochMilliseconds(epoch).toZonedDateTimeISO(timeZone);
|
|
99
|
+
}
|
|
100
|
+
if (isString(input)) {
|
|
101
|
+
return DateTime.#parse(input, zone, loose, dayFirst);
|
|
102
|
+
}
|
|
103
|
+
if (isZonedDateTime(input)) {
|
|
104
|
+
return zone === undefined ? input : input.withTimeZone(timeZone);
|
|
105
|
+
}
|
|
106
|
+
if (isInstant(input)) {
|
|
107
|
+
return input.toZonedDateTimeISO(timeZone);
|
|
108
|
+
}
|
|
109
|
+
if (isPlainDateTime(input)) {
|
|
110
|
+
return input.toZonedDateTime(timeZone);
|
|
111
|
+
}
|
|
112
|
+
if (isPlainDate(input)) {
|
|
113
|
+
return input.toZonedDateTime({ timeZone });
|
|
114
|
+
}
|
|
115
|
+
if (isPlainObject(input)) {
|
|
116
|
+
return guard(
|
|
117
|
+
() => Temporal.ZonedDateTime.from({ ...singularKeys(input), timeZone }, { overflow: 'reject' }),
|
|
118
|
+
'unreadableDateTime',
|
|
119
|
+
JSON.stringify(input),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return refuseType('notADateTime', String(input), {
|
|
123
|
+
explanation: isDevelopment
|
|
124
|
+
? 'a datetime is an ISO string, a Date, epoch milliseconds, a fields object, or a Temporal value'
|
|
125
|
+
: 0,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static #parse(text, zone, loose, dayFirst) {
|
|
130
|
+
const trimmed = text.trim();
|
|
131
|
+
try {
|
|
132
|
+
return guard(
|
|
133
|
+
() => {
|
|
134
|
+
if (/\[[^\]]+\]$/.test(trimmed)) {
|
|
135
|
+
const zoned = Temporal.ZonedDateTime.from(trimmed);
|
|
136
|
+
return zone === undefined ? zoned : zoned.withTimeZone(zoneId(zone));
|
|
137
|
+
}
|
|
138
|
+
if (hasTime.test(trimmed)) {
|
|
139
|
+
return hasOffset.test(trimmed)
|
|
140
|
+
? Temporal.Instant.from(trimmed).toZonedDateTimeISO(zoneId(zone))
|
|
141
|
+
: Temporal.PlainDateTime.from(trimmed).toZonedDateTime(zoneId(zone));
|
|
142
|
+
}
|
|
143
|
+
return Temporal.PlainDate.from(trimmed).toZonedDateTime({ timeZone: zoneId(zone) });
|
|
144
|
+
},
|
|
145
|
+
'unreadableDateTime',
|
|
146
|
+
text,
|
|
147
|
+
isDevelopment
|
|
148
|
+
? "write ISO 8601: '2026-09-06T14:30:00Z', '2026-09-06T14:30' for a wall clock in the zone, or '2026-09-06'. plugins/loose adds { loose: true } for what Date reads"
|
|
149
|
+
: 0,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!loose || error.code !== 'unreadableDateTime') {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const zoned = parseLoose(trimmed, zone, dayFirst);
|
|
157
|
+
if (!zoned) {
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
return zoned;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*******************************
|
|
165
|
+
Reads
|
|
166
|
+
*******************************/
|
|
167
|
+
|
|
168
|
+
get microsecond() {
|
|
169
|
+
return this.#zoned.microsecond;
|
|
170
|
+
}
|
|
171
|
+
get nanosecond() {
|
|
172
|
+
return this.#zoned.nanosecond;
|
|
173
|
+
}
|
|
174
|
+
// the exact instant as a BigInt, the key two datetimes a nanosecond apart still tell apart on
|
|
175
|
+
get epochNanoseconds() {
|
|
176
|
+
return this.#zoned.epochNanoseconds;
|
|
177
|
+
}
|
|
178
|
+
get quarter() {
|
|
179
|
+
return Math.ceil(this.#zoned.month / 3);
|
|
180
|
+
}
|
|
181
|
+
get dayOfYear() {
|
|
182
|
+
return this.#zoned.dayOfYear;
|
|
183
|
+
}
|
|
184
|
+
get weekOfYear() {
|
|
185
|
+
return this.#zoned.weekOfYear;
|
|
186
|
+
}
|
|
187
|
+
get daysInMonth() {
|
|
188
|
+
return this.#zoned.daysInMonth;
|
|
189
|
+
}
|
|
190
|
+
get daysInYear() {
|
|
191
|
+
return this.#zoned.daysInYear;
|
|
192
|
+
}
|
|
193
|
+
get hoursInDay() {
|
|
194
|
+
return this.#zoned.hoursInDay;
|
|
195
|
+
}
|
|
196
|
+
isLeapYear() {
|
|
197
|
+
return this.#zoned.inLeapYear;
|
|
198
|
+
}
|
|
199
|
+
isWeekend() {
|
|
200
|
+
return this.#zoned.dayOfWeek >= 6;
|
|
201
|
+
}
|
|
202
|
+
isWeekday() {
|
|
203
|
+
return this.#zoned.dayOfWeek < 6;
|
|
204
|
+
}
|
|
205
|
+
get date() {
|
|
206
|
+
return new CalendarDate(this.#zoned.toPlainDate());
|
|
207
|
+
}
|
|
208
|
+
get time() {
|
|
209
|
+
return new Time(this.#zoned.toPlainTime());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/*******************************
|
|
213
|
+
Arithmetic
|
|
214
|
+
*******************************/
|
|
215
|
+
|
|
216
|
+
plus(amount, name) {
|
|
217
|
+
return new DateTime(
|
|
218
|
+
guard(() => this.#zoned.add(temporalDurationFrom(amount, name)), 'cannotAdd', `${amount} ${name ?? ''}`),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
minus(amount, name) {
|
|
223
|
+
return new DateTime(
|
|
224
|
+
guard(
|
|
225
|
+
() => this.#zoned.subtract(temporalDurationFrom(amount, name)),
|
|
226
|
+
'cannotSubtract',
|
|
227
|
+
`${amount} ${name ?? ''}`,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
set(fields, value) {
|
|
233
|
+
const changes = isString(fields) ? { [unit(fields)]: value } : singularKeys(fields);
|
|
234
|
+
return new DateTime(
|
|
235
|
+
guard(() => withParts(this.#zoned, changes), 'cannotSet', JSON.stringify(changes)),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
at(when) {
|
|
240
|
+
return this.date.at(when, this.zone);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
in(zone) {
|
|
244
|
+
return new DateTime(this.#zoned.withTimeZone(zoneId(zone)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// a week starts on the configured first day, or on the one given: a picker's first day is a prop
|
|
248
|
+
startOf(name, firstDay) {
|
|
249
|
+
const target = unit(name);
|
|
250
|
+
switch (target) {
|
|
251
|
+
case 'year':
|
|
252
|
+
return new DateTime(this.#zoned.with({ month: 1, day: 1 }).startOfDay());
|
|
253
|
+
case 'quarter':
|
|
254
|
+
return new DateTime(this.#zoned.with({ month: quarterStart(this.month), day: 1 }).startOfDay());
|
|
255
|
+
case 'month':
|
|
256
|
+
return new DateTime(this.#zoned.with({ day: 1 }).startOfDay());
|
|
257
|
+
case 'week':
|
|
258
|
+
return new DateTime(this.#zoned.subtract({ days: (this.weekday - weekStart(firstDay) + 7) % 7 }).startOfDay());
|
|
259
|
+
case 'day':
|
|
260
|
+
return new DateTime(this.#zoned.startOfDay());
|
|
261
|
+
default:
|
|
262
|
+
return new DateTime(this.#zoned.round({ smallestUnit: target, roundingMode: 'floor' }));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// the last millisecond, the precision of Date and of now(). for a query bound prefer range(unit)
|
|
267
|
+
endOf(name, firstDay) {
|
|
268
|
+
return this.startOf(name, firstDay).plus(stepOf(unit(name))).minus({ milliseconds: 1 });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
round(increment, name) {
|
|
272
|
+
return this.#round(increment, name, 'halfExpand');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
floor(increment, name) {
|
|
276
|
+
return this.#round(increment, name, 'floor');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
ceil(increment, name) {
|
|
280
|
+
return this.#round(increment, name, 'ceil');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#round(increment, name, mode) {
|
|
284
|
+
const [count, target] = isString(increment) && !isDigits(increment) ? [1, increment] : [Number(increment), name];
|
|
285
|
+
return new DateTime(
|
|
286
|
+
guard(
|
|
287
|
+
() => this.#zoned.round({ smallestUnit: unit(target), roundingIncrement: count, roundingMode: mode }),
|
|
288
|
+
'cannotRound',
|
|
289
|
+
`${count} ${target}`,
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
next(weekday) {
|
|
295
|
+
const delta = (weekdayNumber(weekday) - this.weekday + 7) % 7 || 7;
|
|
296
|
+
return this.plus({ days: delta });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
previous(weekday) {
|
|
300
|
+
const delta = (this.weekday - weekdayNumber(weekday) + 7) % 7 || 7;
|
|
301
|
+
return this.minus({ days: delta });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/*******************************
|
|
305
|
+
Comparison
|
|
306
|
+
*******************************/
|
|
307
|
+
|
|
308
|
+
equals(other) {
|
|
309
|
+
return this.#compare(other) === 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// exact time only, so another DateTime compares as it is and never re-zones
|
|
313
|
+
#compare(other) {
|
|
314
|
+
const rival = other instanceof DateTime ? other : new DateTime(other, this.zone);
|
|
315
|
+
return Temporal.ZonedDateTime.compare(this.#zoned, rival.toTemporal());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
isBefore(other) {
|
|
319
|
+
return this.#compare(other) < 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
isAfter(other) {
|
|
323
|
+
return this.#compare(other) > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
isSame(other, name, firstDay) {
|
|
327
|
+
if (name === undefined) {
|
|
328
|
+
return this.equals(other);
|
|
329
|
+
}
|
|
330
|
+
return this.startOf(name, firstDay).equals(new DateTime(other, this.zone).startOf(name, firstDay));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
isPast() {
|
|
334
|
+
return this.isBefore(now(this.zone));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
isFuture() {
|
|
338
|
+
return this.isAfter(now(this.zone));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
isToday() {
|
|
342
|
+
return this.date.equals(today(this.zone));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
isTomorrow() {
|
|
346
|
+
return this.date.equals(today(this.zone).plus({ days: 1 }));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
isYesterday() {
|
|
350
|
+
return this.date.equals(today(this.zone).minus({ days: 1 }));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/*******************************
|
|
354
|
+
Measure
|
|
355
|
+
*******************************/
|
|
356
|
+
|
|
357
|
+
// a duration balanced from years down, remembering this moment so it can total in any unit.
|
|
358
|
+
// with a unit, the total as a number
|
|
359
|
+
until(other, name) {
|
|
360
|
+
const end = new DateTime(other, this.zone);
|
|
361
|
+
const span = anchored(this.#zoned.until(end.toTemporal(), { largestUnit: 'year' }), this.#zoned);
|
|
362
|
+
return name === undefined ? span : span.total(name);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
since(other, name) {
|
|
366
|
+
return new DateTime(other, this.zone).until(this, name);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/*******************************
|
|
370
|
+
Output
|
|
371
|
+
*******************************/
|
|
372
|
+
|
|
373
|
+
format(spec, locale) {
|
|
374
|
+
const options = intlOptions('datetime', spec);
|
|
375
|
+
return options ? formatIntl('datetime', this.epoch, options, locale, this.zone) : unknownFormat(spec);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// the instant in UTC, the form every database and API reads. the zone is a view and does not travel
|
|
379
|
+
toString() {
|
|
380
|
+
const nanos = this.#zoned.epochNanoseconds % 1_000_000n;
|
|
381
|
+
const digits = nanos === 0n ? 3 : nanos % 1000n === 0n ? 6 : 9;
|
|
382
|
+
return this.#zoned.toInstant().toString({ fractionalSecondDigits: digits });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
toJSON() {
|
|
386
|
+
return this.toString();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
toJSDate() {
|
|
390
|
+
return new Date(this.epoch);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
toTemporal() {
|
|
394
|
+
return this.#zoned;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
valueOf() {
|
|
398
|
+
return this.epoch;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// + gives the string and < and - the number, the way Date decides
|
|
402
|
+
[Symbol.toPrimitive](hint) {
|
|
403
|
+
return hint === 'number' ? this.epoch : this.toString();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
[inspect]() {
|
|
407
|
+
return `DateTime(${this.#zoned})`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
DateTime.prototype.add = DateTime.prototype.plus;
|
|
412
|
+
DateTime.prototype.subtract = DateTime.prototype.minus;
|
|
413
|
+
DateTime.prototype.remove = DateTime.prototype.minus;
|
|
414
|
+
|
|
415
|
+
// with { loose: true } (via plugins/loose) an unreadable input is null, never a throw
|
|
416
|
+
export const datetime = (input, options) => {
|
|
417
|
+
const settings = zoneOptions(options);
|
|
418
|
+
const build = () =>
|
|
419
|
+
input instanceof DateTime && settings.zone === undefined && settings.dayFirst === undefined
|
|
420
|
+
? input
|
|
421
|
+
: new DateTime(input, settings);
|
|
422
|
+
return settings.loose ? loosely(build) : build();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// millisecond precision, the precision of Date, so a value survives a JSON round trip unchanged
|
|
426
|
+
export const now = (zone) =>
|
|
427
|
+
new DateTime(Temporal.Instant.fromEpochMilliseconds(Date.now()).toZonedDateTimeISO(zoneId(zone)));
|
|
428
|
+
|
|
429
|
+
// the two bounds a query for today writes most
|
|
430
|
+
export const startOfToday = (zone) => now(zone).startOf('day');
|
|
431
|
+
export const endOfToday = (zone) => now(zone).endOf('day');
|
|
432
|
+
|
|
433
|
+
export const isDateTime = (value) => value instanceof DateTime;
|