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.
@@ -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;