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 ADDED
@@ -0,0 +1,7 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025 Jack Lukic
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # <img alt="" src=".github/logo.svg" height="47" align="absmiddle"> Native Dates
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > A thin date library built on the browser's Temporal and Intl APIs
4
+
5
+ - Four nouns for what code actually means: `DateTime`, `CalendarDate`, `Time` and `Duration`
6
+ - Time zones, daylight transitions and calendar math handled by the platform
7
+ - Frozen values that lead in the console with their human-readable `text`
8
+ - Natural phrases (`'1h 30m'`, `today().plus(2, 'days').at('9am')`) and `Intl` presets
9
+ - Plugins you import only if you use them
10
+ - About 6 KB brotli, with zero dependencies
11
+
12
+ Native Dates leaves calendar math and formatting to the platform. The browser's [Temporal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal) and [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) APIs know every time zone, daylight transition and locale, and this library speaks them in plain English: four nouns instead of nine, one word per thought, and values that cannot be mutated in place.
13
+
14
+ ```js
15
+ import { date, duration, now, today } from 'native-dates';
16
+ import '@native-dates/plugins/ranges';
17
+ import '@native-dates/plugins/tokens';
18
+
19
+ const standup = today().next('monday').at('9:30am', 'America/Los_Angeles');
20
+ standup.in('Europe/Berlin').format('dddd, MMM D [at] HH:mm z');
21
+
22
+ const sprint = date('2026-09-01').to(2, 'weeks');
23
+ sprint.contains(today());
24
+ sprint.points('day');
25
+
26
+ const remaining = now().until(standup);
27
+ remaining.total('hours');
28
+ duration('1h 30m').plus(15, 'minutes').format('short');
29
+ ```
30
+
31
+ ## Packages
32
+
33
+ | Package | Description |
34
+ | --- | --- |
35
+ | [native-dates](src) | The four nouns (`DateTime`, `CalendarDate`, `Time`, `Duration`), presets, comparisons and `configure()` |
36
+ | [@native-dates/plugins](packages/plugins) | The plugins (`ranges`, `tokens`, `relative`, `zones`, `loose`, `legacy-date`), each installed by importing it |
37
+ | [@native-dates/polyfill](packages/polyfill) | Loads `temporal-polyfill` only when `globalThis.Temporal` is absent |
38
+ | [@native-dates/core](packages/core) | Native Dates under its scoped name, a thin re-export |
39
+
40
+ ## License
41
+
42
+ [ISC](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "native-dates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.6",
4
+ "title": "Native Dates",
5
+ "description": "Temporal, spoken plainly. Datetimes, dates, times, durations and ranges on the browser's Temporal and Intl APIs",
6
+ "author": "Jack Lukic",
7
+ "license": "ISC",
8
+ "type": "module",
9
+ "sideEffects": false,
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "main": "src/index.js",
14
+ "module": "src/index.js",
15
+ "types": "types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./types/index.d.ts",
19
+ "import": "./src/index.js"
20
+ },
21
+ "./helpers/*": "./src/helpers/*.js"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "types",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "workspaces": [
30
+ ".",
31
+ "packages/*"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.js",
35
+ "size": "node tools/size.js",
36
+ "format": "npx dprint fmt",
37
+ "setup": "npx simple-git-hooks"
38
+ },
39
+ "devDependencies": {
40
+ "dprint": "^0.50.0",
41
+ "esbuild": "^0.25.0",
42
+ "simple-git-hooks": "^2.11.1"
43
+ },
44
+ "simple-git-hooks": {
45
+ "pre-commit": "bash tools/hooks/pre-commit",
46
+ "commit-msg": "bash tools/hooks/commit-msg \"$1\""
47
+ }
48
+ }
@@ -0,0 +1,393 @@
1
+ import { DateTime } from './date-time.js';
2
+ import { anchored } from './duration.js';
3
+ import { guard, loosely, parseLoose, refuse, refuseType, requireLoose } from './helpers/errors.js';
4
+ import { fieldsFrom, temporalDurationOf } from './helpers/fields.js';
5
+ import { formatIntl, intlOptions, unknownFormat } from './helpers/format.js';
6
+ import { IS_CALENDAR_DATE } from './helpers/identity.js';
7
+ import { isDate, isDevelopment, isNumber, isPlainObject, isString } from './helpers/types.js';
8
+ import {
9
+ inspect,
10
+ isDigits,
11
+ isPlainDate,
12
+ isPlainDateTime,
13
+ isZonedDateTime,
14
+ plural,
15
+ quarterStart,
16
+ singularKeys,
17
+ stepOf,
18
+ timeUnits,
19
+ unit,
20
+ weekdayNumber,
21
+ wholeNumbers,
22
+ withParts,
23
+ } from './helpers/units.js';
24
+ import { weekStart, zoneId } from './helpers/zones.js';
25
+ import { Time } from './time.js';
26
+
27
+ /*
28
+ a calendar date with no time and no zone: a due date, a birthday, a booking night. it is not a
29
+ moment, so it never shifts when read from another zone
30
+ */
31
+
32
+ const hasTime = /[T ]\d{1,2}(?::?\d{2})?/;
33
+ const hasOffset = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/;
34
+
35
+ export class CalendarDate {
36
+ // brand calendar date
37
+ get [IS_CALENDAR_DATE]() {
38
+ return true;
39
+ }
40
+ static [Symbol.hasInstance](value) {
41
+ return !!value?.[IS_CALENDAR_DATE];
42
+ }
43
+
44
+ #plain;
45
+
46
+ // date(2026, 9, 6), date(input, zone), or date(input, { zone, loose })
47
+ constructor(input, monthOrOptions, day) {
48
+ if (isDigits(input) && (isNumber(monthOrOptions) || isDigits(monthOrOptions))) {
49
+ [input, monthOrOptions, day] = [Number(input), Number(monthOrOptions), day === undefined ? day : Number(day)];
50
+ }
51
+ const settings = isPlainObject(monthOrOptions)
52
+ ? monthOrOptions
53
+ : isString(monthOrOptions)
54
+ ? { zone: monthOrOptions }
55
+ : {};
56
+ this.#plain =
57
+ isPlainDate(input) && settings.zone === undefined && !settings.loose && settings.dayFirst === undefined
58
+ ? input
59
+ : CalendarDate.#read(input, isNumber(monthOrOptions) ? monthOrOptions : undefined, day, settings);
60
+ // the parts are own properties, so a value prints them in a console without a click, in the order a
61
+ // preview reads: the text first, filled once the parts it reads are in, then month, day, year
62
+ this.text = undefined;
63
+ const plain = this.#plain;
64
+ this.month = plain.month;
65
+ this.day = plain.day;
66
+ this.year = plain.year;
67
+ this.weekday = plain.dayOfWeek;
68
+ this.text = this.format();
69
+ Object.freeze(this);
70
+ }
71
+
72
+ static #read(input, month, day, settings) {
73
+ if (settings.zone !== undefined) {
74
+ zoneId(settings.zone);
75
+ }
76
+ if (settings.loose || settings.dayFirst !== undefined) {
77
+ requireLoose(settings.loose ? 'loose' : 'dayFirst');
78
+ }
79
+ if (input === undefined || input === null) {
80
+ return refuseType('notADate', String(input), {
81
+ explanation: isDevelopment
82
+ ? 'today() is the current day. a missing value is refused so an absent field never silently becomes today'
83
+ : 0,
84
+ });
85
+ }
86
+ if (isNumber(input) && isNumber(month)) {
87
+ return guard(
88
+ () => {
89
+ if (!wholeNumbers([input, month, day])) {
90
+ throw new RangeError('a date reads whole numbers');
91
+ }
92
+ return Temporal.PlainDate.from({ year: input, month, day: day ?? 1 }, { overflow: 'reject' });
93
+ },
94
+ 'unreadableDate',
95
+ `${input}-${month}-${day}`,
96
+ );
97
+ }
98
+ if (input instanceof CalendarDate) {
99
+ return input.toTemporal();
100
+ }
101
+ if (input instanceof DateTime) {
102
+ return input.toTemporal().toPlainDate();
103
+ }
104
+ if (isNumber(input)) {
105
+ return refuseType('notADate', String(input), {
106
+ explanation: isDevelopment
107
+ ? 'a number alone is ambiguous. epoch milliseconds are a datetime, datetime(n).date, and a year needs its month and day, date(2026, 9, 6)'
108
+ : 0,
109
+ });
110
+ }
111
+ if (isDate(input)) {
112
+ return new DateTime(input, settings).toTemporal().toPlainDate();
113
+ }
114
+ if (isString(input)) {
115
+ try {
116
+ return CalendarDate.#parse(input);
117
+ }
118
+ catch (error) {
119
+ if (!settings.loose || (error.code !== 'notADate' && error.code !== 'unreadableDate')) {
120
+ throw error;
121
+ }
122
+ const zoned = parseLoose(input, settings.zone, settings.dayFirst);
123
+ if (!zoned) {
124
+ throw error;
125
+ }
126
+ return zoned.toPlainDate();
127
+ }
128
+ }
129
+ if (isZonedDateTime(input) || isPlainDateTime(input)) {
130
+ return input.toPlainDate();
131
+ }
132
+ if (isPlainObject(input)) {
133
+ return guard(
134
+ () => Temporal.PlainDate.from(singularKeys(input), { overflow: 'reject' }),
135
+ 'unreadableDate',
136
+ JSON.stringify(input),
137
+ );
138
+ }
139
+ return refuseType('notADate', String(input), {
140
+ explanation: isDevelopment
141
+ ? "a date is an ISO string like '2026-09-06', three numbers, a fields object, a datetime, or a Temporal value"
142
+ : 0,
143
+ });
144
+ }
145
+
146
+ // a wall-clock string keeps its day and drops the clock. a string carrying Z or an offset is an
147
+ // instant, and which day it falls on depends on the zone, so it is refused with the two ways to choose
148
+ static #parse(text) {
149
+ const trimmed = text.trim();
150
+ if (hasTime.test(trimmed) && hasOffset.test(trimmed)) {
151
+ refuseType('notADate', text, {
152
+ explanation: isDevelopment
153
+ ? 'that string is an instant. datetime(text, zone).date chooses its day in a zone, and so does { loose: true, zone } with plugins/loose'
154
+ : 0,
155
+ });
156
+ }
157
+ return guard(
158
+ () => Temporal.PlainDate.from(trimmed),
159
+ 'unreadableDate',
160
+ text,
161
+ isDevelopment ? "write ISO 8601: '2026-09-06'. plugins/loose adds { loose: true } for what Date reads" : 0,
162
+ );
163
+ }
164
+
165
+ /*******************************
166
+ Reads
167
+ *******************************/
168
+
169
+ get quarter() {
170
+ return Math.ceil(this.#plain.month / 3);
171
+ }
172
+ get dayOfYear() {
173
+ return this.#plain.dayOfYear;
174
+ }
175
+ get weekOfYear() {
176
+ return this.#plain.weekOfYear;
177
+ }
178
+ get daysInMonth() {
179
+ return this.#plain.daysInMonth;
180
+ }
181
+ get daysInYear() {
182
+ return this.#plain.daysInYear;
183
+ }
184
+ isLeapYear() {
185
+ return this.#plain.inLeapYear;
186
+ }
187
+ isWeekend() {
188
+ return this.#plain.dayOfWeek >= 6;
189
+ }
190
+ isWeekday() {
191
+ return this.#plain.dayOfWeek < 6;
192
+ }
193
+
194
+ /*******************************
195
+ Arithmetic
196
+ *******************************/
197
+
198
+ plus(amount, name) {
199
+ return new CalendarDate(
200
+ guard(() => this.#plain.add(CalendarDate.#dateFields(amount, name)), 'cannotAdd', `${amount} ${name ?? ''}`),
201
+ );
202
+ }
203
+
204
+ minus(amount, name) {
205
+ return new CalendarDate(
206
+ guard(
207
+ () => this.#plain.subtract(CalendarDate.#dateFields(amount, name)),
208
+ 'cannotSubtract',
209
+ `${amount} ${name ?? ''}`,
210
+ ),
211
+ );
212
+ }
213
+
214
+ static #dateFields(amount, name) {
215
+ const fields = fieldsFrom(amount, name);
216
+ const clock = timeUnits.find((time) => fields[plural(time)]);
217
+ if (clock) {
218
+ refuse('notADateUnit', `${clock}s`, {
219
+ explanation: isDevelopment
220
+ ? 'a date has no clock. add days, or combine it with a time first: date.at(time, zone).plus(hours)'
221
+ : 0,
222
+ });
223
+ }
224
+ return temporalDurationOf(fields);
225
+ }
226
+
227
+ set(fields, value) {
228
+ const changes = isString(fields) ? { [unit(fields)]: value } : singularKeys(fields);
229
+ return new CalendarDate(
230
+ guard(() => withParts(this.#plain, changes), 'cannotSet', JSON.stringify(changes)),
231
+ );
232
+ }
233
+
234
+ // a week starts on the configured first day, or on the one given: a picker's first day is a prop
235
+ startOf(name, firstDay) {
236
+ switch (unit(name)) {
237
+ case 'year':
238
+ return new CalendarDate(this.#plain.with({ month: 1, day: 1 }));
239
+ case 'quarter':
240
+ return new CalendarDate(this.#plain.with({ month: quarterStart(this.month), day: 1 }));
241
+ case 'month':
242
+ return new CalendarDate(this.#plain.with({ day: 1 }));
243
+ case 'week':
244
+ return new CalendarDate(this.#plain.subtract({ days: (this.weekday - weekStart(firstDay) + 7) % 7 }));
245
+ case 'day':
246
+ return this;
247
+ default:
248
+ return refuse('notADateUnit', String(name), {
249
+ explanation: isDevelopment ? 'a date starts at year, quarter, month, week or day' : 0,
250
+ });
251
+ }
252
+ }
253
+
254
+ endOf(name, firstDay) {
255
+ return this.startOf(name, firstDay).plus(stepOf(unit(name))).minus({ days: 1 });
256
+ }
257
+
258
+ next(weekday) {
259
+ const delta = (weekdayNumber(weekday) - this.weekday + 7) % 7 || 7;
260
+ return this.plus({ days: delta });
261
+ }
262
+
263
+ previous(weekday) {
264
+ const delta = (this.weekday - weekdayNumber(weekday) + 7) % 7 || 7;
265
+ return this.minus({ days: delta });
266
+ }
267
+
268
+ /*******************************
269
+ Comparison
270
+ *******************************/
271
+
272
+ equals(other) {
273
+ return this.#compare(other) === 0;
274
+ }
275
+
276
+ #compare(other) {
277
+ return Temporal.PlainDate.compare(this.#plain, new CalendarDate(other).toTemporal());
278
+ }
279
+
280
+ isBefore(other) {
281
+ return this.#compare(other) < 0;
282
+ }
283
+
284
+ isAfter(other) {
285
+ return this.#compare(other) > 0;
286
+ }
287
+
288
+ isSame(other, name, firstDay) {
289
+ if (name === undefined) {
290
+ return this.equals(other);
291
+ }
292
+ return this.startOf(name, firstDay).equals(new CalendarDate(other).startOf(name, firstDay));
293
+ }
294
+
295
+ isPast(zone) {
296
+ return this.isBefore(today(zone));
297
+ }
298
+
299
+ isFuture(zone) {
300
+ return this.isAfter(today(zone));
301
+ }
302
+
303
+ isToday(zone) {
304
+ return this.equals(today(zone));
305
+ }
306
+
307
+ isTomorrow(zone) {
308
+ return this.equals(today(zone).plus({ days: 1 }));
309
+ }
310
+
311
+ isYesterday(zone) {
312
+ return this.equals(today(zone).minus({ days: 1 }));
313
+ }
314
+
315
+ /*******************************
316
+ Measure
317
+ *******************************/
318
+
319
+ until(other, name) {
320
+ const end = new CalendarDate(other);
321
+ const span = anchored(this.#plain.until(end.toTemporal(), { largestUnit: 'year' }), this.#plain);
322
+ return name === undefined ? span : span.total(name);
323
+ }
324
+
325
+ since(other, name) {
326
+ return new CalendarDate(other).until(this, name);
327
+ }
328
+
329
+ at(when, zone) {
330
+ const plainTime = new Time(when ?? '00:00').toTemporal();
331
+ return new DateTime(this.#plain.toZonedDateTime({ timeZone: zoneId(zone), plainTime }));
332
+ }
333
+
334
+ /*******************************
335
+ Output
336
+ *******************************/
337
+
338
+ format(spec, locale) {
339
+ const options = intlOptions('date', spec);
340
+ return options ? formatIntl('date', this.#plain, options, locale) : unknownFormat(spec);
341
+ }
342
+
343
+ toString() {
344
+ return this.#plain.toString();
345
+ }
346
+
347
+ toJSON() {
348
+ return this.#plain.toString();
349
+ }
350
+
351
+ toJSDate(zone) {
352
+ return this.at('00:00', zone).toJSDate();
353
+ }
354
+
355
+ toTemporal() {
356
+ return this.#plain;
357
+ }
358
+
359
+ valueOf() {
360
+ return refuse('notANumber', String(this), {
361
+ explanation: isDevelopment
362
+ ? 'a date is not a point on the number line. compare with isBefore, isAfter or equals, or measure with until'
363
+ : 0,
364
+ });
365
+ }
366
+
367
+ // + gives the string, while < and - still refuse through valueOf
368
+ [Symbol.toPrimitive](hint) {
369
+ return hint === 'number' ? this.valueOf() : this.toString();
370
+ }
371
+
372
+ [inspect]() {
373
+ return `CalendarDate(${this.#plain})`;
374
+ }
375
+ }
376
+
377
+ CalendarDate.prototype.add = CalendarDate.prototype.plus;
378
+ CalendarDate.prototype.subtract = CalendarDate.prototype.minus;
379
+ CalendarDate.prototype.remove = CalendarDate.prototype.minus;
380
+
381
+ export const date = (input, monthOrOptions, day) => {
382
+ const build = () =>
383
+ input instanceof CalendarDate && monthOrOptions === undefined
384
+ ? input
385
+ : new CalendarDate(input, monthOrOptions, day);
386
+ return isPlainObject(monthOrOptions) && monthOrOptions.loose ? loosely(build) : build();
387
+ };
388
+
389
+ export const today = (zone) => new CalendarDate(Temporal.Now.plainDateISO(zoneId(zone)));
390
+ export const tomorrow = (zone) => today(zone).plus({ days: 1 });
391
+ export const yesterday = (zone) => today(zone).minus({ days: 1 });
392
+
393
+ export const isCalendarDate = (value) => value instanceof CalendarDate;