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,341 @@
1
+ import { guard, loosely, refuse, unreadable } from './helpers/errors.js';
2
+ import {
3
+ addFields,
4
+ clockFields,
5
+ fieldNames,
6
+ fieldsFrom,
7
+ fieldsOf,
8
+ spill,
9
+ temporalDurationOf,
10
+ } from './helpers/fields.js';
11
+ import { IS_DURATION } from './helpers/identity.js';
12
+ import { durationFormat, unitFormat } from './helpers/intl.js';
13
+ import { isDevelopment, isPlainObject } from './helpers/types.js';
14
+ import { inspect, isTemporalDuration, unit } from './helpers/units.js';
15
+ import { locale as pickLocale } from './helpers/zones.js';
16
+
17
+ /*
18
+ a length of time. fields stay as written (90 minutes is 90 minutes until you balance it), and a
19
+ duration that came from until() or since() remembers where it started, so months and years total
20
+ */
21
+
22
+ const subsecond = fieldNames.indexOf('milliseconds');
23
+ const styles = ['long', 'short', 'narrow', 'digital'];
24
+
25
+ export class Duration {
26
+ // brand duration
27
+ get [IS_DURATION]() {
28
+ return true;
29
+ }
30
+ static [Symbol.hasInstance](value) {
31
+ return !!value?.[IS_DURATION];
32
+ }
33
+
34
+ #temporal;
35
+ #anchor;
36
+
37
+ constructor(input, name, anchor) {
38
+ this.#temporal = isTemporalDuration(input) ? input : temporalDurationOf(fieldsFrom(input, name));
39
+ this.#anchor = anchor;
40
+ // the parts are own properties, so a value prints them in a console without a click. the text is
41
+ // keyed first so a preview leads with it
42
+ this.text = undefined;
43
+ const temporal = this.#temporal;
44
+ this.years = temporal.years;
45
+ this.months = temporal.months;
46
+ this.weeks = temporal.weeks;
47
+ this.days = temporal.days;
48
+ this.hours = temporal.hours;
49
+ this.minutes = temporal.minutes;
50
+ this.seconds = temporal.seconds;
51
+ this.milliseconds = temporal.milliseconds;
52
+ this.sign = temporal.sign;
53
+ this.anchor = anchor;
54
+ this.text = this.format('long');
55
+ Object.freeze(this);
56
+ }
57
+
58
+ /*******************************
59
+ Reads
60
+ *******************************/
61
+
62
+ get microseconds() {
63
+ return this.#temporal.microseconds;
64
+ }
65
+ get nanoseconds() {
66
+ return this.#temporal.nanoseconds;
67
+ }
68
+ isZero() {
69
+ return this.#temporal.blank;
70
+ }
71
+ isNegative() {
72
+ return this.#temporal.sign < 0;
73
+ }
74
+
75
+ toFields() {
76
+ return fieldsOf(this.#temporal);
77
+ }
78
+
79
+ /*******************************
80
+ Arithmetic
81
+ *******************************/
82
+
83
+ plus(other, name) {
84
+ return new Duration(
85
+ temporalDurationOf(addFields(this.toFields(), fieldsFrom(other, name), 1)),
86
+ undefined,
87
+ this.#anchor,
88
+ );
89
+ }
90
+
91
+ minus(other, name) {
92
+ return new Duration(
93
+ temporalDurationOf(addFields(this.toFields(), fieldsFrom(other, name), -1)),
94
+ undefined,
95
+ this.#anchor,
96
+ );
97
+ }
98
+
99
+ times(factor) {
100
+ const fields = {};
101
+ for (const [field, value] of Object.entries(this.toFields())) {
102
+ spill(fields, unit(field), value * factor);
103
+ }
104
+ return new Duration(temporalDurationOf(fields), undefined, this.#anchor);
105
+ }
106
+
107
+ negated() {
108
+ return new Duration(this.#temporal.negated(), undefined, this.#anchor);
109
+ }
110
+
111
+ abs() {
112
+ return new Duration(this.#temporal.abs(), undefined, this.#anchor);
113
+ }
114
+
115
+ // carries overflow upward: 90 minutes balances to an hour and a half. a length stops at hours, since
116
+ // a day is a calendar unit and folding 36 hours into one would move a deadline across a daylight
117
+ // saving change. an anchored duration knows its calendar, so days are its ceiling until asked for
118
+ // weeks, months or years, and an explicit unit is taken at its word either way
119
+ balance(largest) {
120
+ if (!this.#anchor) {
121
+ Duration.#requireAnchor(this, 'balance');
122
+ if (largest === undefined) {
123
+ return Duration.#balancedClock(this);
124
+ }
125
+ const target = unit(largest);
126
+ if (target === 'week') {
127
+ return Duration.#balancedWeeks(this);
128
+ }
129
+ if (target === 'month' || target === 'quarter' || target === 'year') {
130
+ refuse('needsAnchor', `balance to ${target}`, {
131
+ explanation: isDevelopment
132
+ ? 'a month has no fixed length. balance a duration taken from a.until(b), which knows its calendar'
133
+ : 0,
134
+ });
135
+ }
136
+ return new Duration(
137
+ guard(() => Duration.#fixed(this).round({ largestUnit: target }), 'cannotBalance', String(this)),
138
+ );
139
+ }
140
+ return new Duration(
141
+ guard(
142
+ () => this.#temporal.round({ largestUnit: unit(largest ?? 'day'), relativeTo: this.#anchor }),
143
+ 'cannotBalance',
144
+ String(this),
145
+ ),
146
+ undefined,
147
+ this.#anchor,
148
+ );
149
+ }
150
+
151
+ round(smallest) {
152
+ const target = unit(smallest);
153
+ return new Duration(
154
+ guard(
155
+ () => Duration.#fixed(this).round({ smallestUnit: target, relativeTo: this.#anchor }),
156
+ 'cannotRound',
157
+ String(this),
158
+ ),
159
+ undefined,
160
+ this.#anchor,
161
+ );
162
+ }
163
+
164
+ // Temporal treats a week as seven days only when told where it starts. in the ISO calendar that is
165
+ // always true, so an unanchored duration folds weeks into days before it totals or balances. reads
166
+ // through the public face, so a duration from another copy of this package fixes the same way
167
+ static #fixed(duration) {
168
+ const temporal = duration.toTemporal();
169
+ if (duration.anchor || !temporal.weeks) {
170
+ return temporal;
171
+ }
172
+ return temporalDurationOf(addFields({ ...duration.toFields(), weeks: 0 }, { days: temporal.weeks * 7 }, 1));
173
+ }
174
+
175
+ // weeks are already days here, and months or years were refused, so days are the one calendar field left
176
+ static #balancedClock(duration) {
177
+ Duration.#requireAnchor(duration, 'balance');
178
+ const { days, ...clock } = fieldsOf(Duration.#fixed(duration));
179
+ const balanced = fieldsOf(temporalDurationOf(clock).round({ largestUnit: 'hour' }));
180
+ return new Duration(temporalDurationOf(days ? { days, ...balanced } : balanced));
181
+ }
182
+
183
+ // a week is seven days in the ISO calendar, so it needs no anchor: the days fold in sevens
184
+ static #balancedWeeks(duration) {
185
+ const { days = 0, ...clock } = fieldsOf(Duration.#fixed(duration).round({ largestUnit: 'day' }));
186
+ const weeks = Math.trunc(days / 7);
187
+ const rest = days - weeks * 7;
188
+ return new Duration(temporalDurationOf({ ...(weeks && { weeks }), ...(rest && { days: rest }), ...clock }));
189
+ }
190
+
191
+ static #requireAnchor(duration, verb) {
192
+ const fields = duration.toFields();
193
+ if (!duration.anchor && (fields.years || fields.months)) {
194
+ refuse('needsAnchor', `${verb} of ${duration}`, {
195
+ explanation: isDevelopment
196
+ ? 'months and years have no fixed length. take the duration from a.until(b), which remembers a, or write it in days'
197
+ : 0,
198
+ });
199
+ }
200
+ }
201
+
202
+ /*******************************
203
+ Measure
204
+ *******************************/
205
+
206
+ total(name) {
207
+ const target = unit(name);
208
+ if (target === 'quarter') {
209
+ return this.total('month') / 3;
210
+ }
211
+ if (this.#anchor) {
212
+ return guard(
213
+ () => this.#temporal.total({ unit: target, relativeTo: this.#anchor }),
214
+ 'cannotTotal',
215
+ `${this} in ${target}`,
216
+ );
217
+ }
218
+ Duration.#requireAnchor(this, `total in ${target}`);
219
+ if (target === 'year' || target === 'month') {
220
+ refuse('needsAnchor', `total in ${target}`, {
221
+ explanation: isDevelopment
222
+ ? 'a month has no fixed length. total a duration taken from a.until(b), which knows its calendar'
223
+ : 0,
224
+ });
225
+ }
226
+ const fixed = Duration.#fixed(this);
227
+ return guard(
228
+ () => (target === 'week' ? fixed.total('day') / 7 : fixed.total(target)),
229
+ 'cannotTotal',
230
+ `${this} in ${target}`,
231
+ );
232
+ }
233
+
234
+ compare(other) {
235
+ const rival = other instanceof Duration ? other : new Duration(other);
236
+ const [mine, theirs] = [Duration.#fixed(this), Duration.#fixed(rival)];
237
+ // the same fields are the same length in any calendar, so a month equals a month without an anchor
238
+ if (mine.toString() === theirs.toString()) {
239
+ return 0;
240
+ }
241
+ const anchor = this.#anchor ?? rival.anchor;
242
+ if (!anchor) {
243
+ Duration.#requireAnchor(this, 'compare');
244
+ Duration.#requireAnchor(rival, 'compare');
245
+ }
246
+ return Temporal.Duration.compare(mine, theirs, anchor ? { relativeTo: anchor } : undefined);
247
+ }
248
+
249
+ equals(other) {
250
+ return this.compare(other) === 0;
251
+ }
252
+
253
+ /*******************************
254
+ Output
255
+ *******************************/
256
+
257
+ // Intl.DurationFormat over the calendar fields as written and the clock balanced, so a stored count of
258
+ // milliseconds reads as hours and minutes. the sub-second fields only show when nothing larger is set,
259
+ // so a wall-clock difference reads as hours and minutes and a timer reads as milliseconds
260
+ format(style = 'long', locale) {
261
+ if (!styles.includes(style)) {
262
+ refuse('unknownFormat', String(style), {
263
+ explanation: isDevelopment ? "a duration formats as 'long', 'short', 'narrow' or 'digital'" : 0,
264
+ });
265
+ }
266
+ const written = this.toFields();
267
+ const clock = fieldsOf(temporalDurationOf(clockFields(written)).round({ largestUnit: 'hour' }));
268
+ const fields = { ...written, ...Object.fromEntries(fieldNames.slice(4).map((field) => [field, clock[field]])) };
269
+ const large = fieldNames.slice(0, subsecond).some((field) => fields[field]);
270
+ const shown = {};
271
+ for (const [index, field] of fieldNames.entries()) {
272
+ if (fields[field] && (!large || index < subsecond)) {
273
+ shown[field] = fields[field];
274
+ }
275
+ }
276
+ // Intl.DurationFormat prints nothing for a zero duration, so zero is spelled out as seconds
277
+ if (this.isZero()) {
278
+ const unitDisplay = style === 'long' ? 'long' : style === 'narrow' ? 'narrow' : 'short';
279
+ return style === 'digital'
280
+ ? durationFormat(pickLocale(locale), style, 'always').format({ hours: 0 })
281
+ : unitFormat(pickLocale(locale), 'second', unitDisplay).format(0);
282
+ }
283
+ return durationFormat(pickLocale(locale), style, 'auto').format(shown);
284
+ }
285
+
286
+ toString() {
287
+ return this.#temporal.toString();
288
+ }
289
+
290
+ toJSON() {
291
+ return this.#temporal.toString();
292
+ }
293
+
294
+ toTemporal() {
295
+ return this.#temporal;
296
+ }
297
+
298
+ toMilliseconds() {
299
+ return this.total('millisecond');
300
+ }
301
+
302
+ valueOf() {
303
+ return this.toMilliseconds();
304
+ }
305
+
306
+ // + gives the string and < and - the number, the way Date decides
307
+ [Symbol.toPrimitive](hint) {
308
+ return hint === 'number' ? this.toMilliseconds() : this.toString();
309
+ }
310
+
311
+ [inspect]() {
312
+ return `Duration(${this.#temporal})${this.#anchor ? ` from ${this.#anchor}` : ''}`;
313
+ }
314
+ }
315
+
316
+ Duration.prototype.add = Duration.prototype.plus;
317
+ Duration.prototype.subtract = Duration.prototype.minus;
318
+ Duration.prototype.remove = Duration.prototype.minus;
319
+
320
+ // duration(input, unit), or duration(input, { loose })
321
+ export const duration = (input, unitOrOptions) => {
322
+ const hasOptions = isPlainObject(unitOrOptions);
323
+ const name = hasOptions ? undefined : unitOrOptions;
324
+ const build = () => (input instanceof Duration && name === undefined ? input : new Duration(input, name));
325
+ return hasOptions && unitOrOptions.loose ? loosely(build, unreadable.length) : build();
326
+ };
327
+
328
+ // a duration that remembers the point it was measured from, so months and years can total
329
+ export const anchored = (temporal, anchor) => new Duration(temporal, undefined, anchor);
330
+
331
+ export const isDuration = (value) => value instanceof Duration;
332
+
333
+ // a duration is a fields object, a phrase, or one of these: hours(2), days(3)
334
+ export const years = (count) => new Duration(count, 'year');
335
+ export const months = (count) => new Duration(count, 'month');
336
+ export const weeks = (count) => new Duration(count, 'week');
337
+ export const days = (count) => new Duration(count, 'day');
338
+ export const hours = (count) => new Duration(count, 'hour');
339
+ export const minutes = (count) => new Duration(count, 'minute');
340
+ export const seconds = (count) => new Duration(count, 'second');
341
+ export const milliseconds = (count) => new Duration(count, 'millisecond');
@@ -0,0 +1,126 @@
1
+ import { CalendarDate } from '../calendar-date.js';
2
+ import { DateTime } from '../date-time.js';
3
+ import { Time } from '../time.js';
4
+ import { refuse, refuseType } from './errors.js';
5
+ import {
6
+ IS_CALENDAR_DATE,
7
+ IS_DATE_RANGE,
8
+ IS_DATE_TIME,
9
+ IS_DATE_TIME_RANGE,
10
+ IS_DURATION,
11
+ IS_RANGE,
12
+ IS_TIME,
13
+ IS_TIME_RANGE,
14
+ } from './identity.js';
15
+ import { isArray, isDate, isDevelopment, isString } from './types.js';
16
+ import { isInstant, isPlainDate, isPlainDateTime, isPlainTime, isZonedDateTime } from './units.js';
17
+
18
+ const looksLikeTime = /^\d{1,2}(?::\d{2}){0,2}(?:\.\d+)?\s*(?:[ap]\.?m\.?)?$/i;
19
+ const looksLikeDateTime = /[T ]\d|[zZ]$|\[[^\]]+\]$/;
20
+
21
+ // a string reads by its shape: a clock is a time, anything carrying a time or zone is a datetime, the
22
+ // rest is a date. every other input already knows what it is
23
+ export const point = (input) => {
24
+ if (input?.[IS_DATE_TIME] || input?.[IS_CALENDAR_DATE] || input?.[IS_TIME]) {
25
+ return input;
26
+ }
27
+ if (isString(input)) {
28
+ const text = input.trim();
29
+ if (looksLikeTime.test(text)) {
30
+ return new Time(text);
31
+ }
32
+ if (looksLikeDateTime.test(text)) {
33
+ return new DateTime(text);
34
+ }
35
+ return new CalendarDate(text);
36
+ }
37
+ if (isDate(input) || isZonedDateTime(input) || isInstant(input) || isPlainDateTime(input)) {
38
+ return new DateTime(input);
39
+ }
40
+ if (isPlainDate(input)) {
41
+ return new CalendarDate(input);
42
+ }
43
+ if (isPlainTime(input)) {
44
+ return new Time(input);
45
+ }
46
+ return refuseType('notAPoint', String(input), {
47
+ explanation: isDevelopment ? 'a point is a datetime, a date or a time, or a string that reads as one' : 0,
48
+ });
49
+ };
50
+
51
+ export const kindOf = (value) => {
52
+ if (value?.[IS_DATE_TIME]) {
53
+ return 'datetime';
54
+ }
55
+ if (value?.[IS_CALENDAR_DATE]) {
56
+ return 'date';
57
+ }
58
+ if (value?.[IS_TIME]) {
59
+ return 'time';
60
+ }
61
+ if (value?.[IS_DURATION]) {
62
+ return 'duration';
63
+ }
64
+ if (value?.[IS_DATE_RANGE]) {
65
+ return 'dateRange';
66
+ }
67
+ if (value?.[IS_DATE_TIME_RANGE]) {
68
+ return 'datetimeRange';
69
+ }
70
+ if (value?.[IS_TIME_RANGE]) {
71
+ return 'timeRange';
72
+ }
73
+ return undefined;
74
+ };
75
+
76
+ const sameKind = (left, right) => {
77
+ if (kindOf(left) !== kindOf(right)) {
78
+ refuseType('mixedKinds', `${kindOf(left)} with ${kindOf(right)}`, {
79
+ explanation: isDevelopment
80
+ ? 'compare a datetime with a datetime, a date with a date. convert first: datetime.date, date.at(time, zone)'
81
+ : 0,
82
+ });
83
+ }
84
+ };
85
+
86
+ // a raw value beside a point reads as that point's kind, the way the point's own methods read it
87
+ const asKindOf = (left, value) => {
88
+ if (kindOf(value) !== undefined) {
89
+ return value;
90
+ }
91
+ const kind = kindOf(left);
92
+ if (kind === 'datetime') {
93
+ return new DateTime(value, left.zone);
94
+ }
95
+ return kind === 'date' ? new CalendarDate(value) : new Time(value);
96
+ };
97
+
98
+ // a sort comparator. both sides must be the same kind of thing, a raw value reads as the first's kind.
99
+ // ranges order by their start, then by their end, the order a database gives its range types
100
+ export const compare = (a, b) => {
101
+ if (a?.[IS_DURATION]) {
102
+ return a.compare(b);
103
+ }
104
+ if (a?.[IS_RANGE]) {
105
+ sameKind(a, b);
106
+ return compare(a.start, b.start) || compare(a.end, b.end);
107
+ }
108
+ const left = point(a);
109
+ const right = asKindOf(left, b);
110
+ sameKind(left, right);
111
+ return left.isBefore(right) ? -1 : left.isAfter(right) ? 1 : 0;
112
+ };
113
+
114
+ const pick = (values, verb, wins) => {
115
+ const raw = values.length === 1 && isArray(values[0]) ? values[0] : values;
116
+ if (!raw.length) {
117
+ refuse('noPoints', verb, { explanation: isDevelopment ? `${verb}() needs at least one point` : 0 });
118
+ }
119
+ const head = point(raw[0]);
120
+ const points = raw.map((value, index) => (index === 0 ? head : asKindOf(head, value)));
121
+ return points.reduce((best, next) => (wins(compare(next, best)) ? next : best));
122
+ };
123
+
124
+ export const earliest = (...values) => pick(values, 'earliest', (order) => order < 0);
125
+
126
+ export const latest = (...values) => pick(values, 'latest', (order) => order > 0);
@@ -0,0 +1,89 @@
1
+ import { isDevelopment } from './types.js';
2
+
3
+ const makeRefuse = (ErrorClass) => {
4
+ const throwError = (code, at, { explanation } = {}) => {
5
+ const line = `dates refused [${code}] ${at}`;
6
+ const built = new ErrorClass(explanation ? `${line}\n${explanation}` : line);
7
+ built.code = code;
8
+ built.at = at;
9
+ Error.captureStackTrace?.(built, throwError);
10
+ throw built;
11
+ };
12
+ return throwError;
13
+ };
14
+
15
+ export const refuse = makeRefuse(RangeError);
16
+ export const refuseType = makeRefuse(TypeError);
17
+
18
+ // Temporal's own RangeErrors are precise but speak in spec terms. ours keep the code and echo the input
19
+ export const guard = (attempt, code, at, explanation) => {
20
+ try {
21
+ return attempt();
22
+ }
23
+ catch (error) {
24
+ if (error?.code) {
25
+ throw error;
26
+ }
27
+ return refuse(code, at, { explanation: explanation || error.message.replace(/^Temporal error: /, '') });
28
+ }
29
+ };
30
+
31
+ // { loose: true } gives null for input that cannot be read, and only for that. a bad zone is a
32
+ // mistake in the code, not in the data, and still throws. a bad unit is data when it sits inside a
33
+ // duration string and code when it names a method's unit, so only the duration door forgives it
34
+ const point = new Set([
35
+ 'unreadableDateTime',
36
+ 'unreadableDate',
37
+ 'unreadableTime',
38
+ 'notADateTime',
39
+ 'notADate',
40
+ 'notATime',
41
+ 'notFinite',
42
+ ]);
43
+ const length = new Set([
44
+ 'unreadableDuration',
45
+ 'notADuration',
46
+ 'unknownUnit',
47
+ 'mixedSigns',
48
+ 'notFinite',
49
+ 'fractionalMonth',
50
+ ]);
51
+
52
+ export const unreadable = { point, length, range: new Set([...point, ...length]) };
53
+
54
+ export const isUnreadable = (error, codes = point) => codes.has(error?.code);
55
+
56
+ // plugins/loose supplies Date.parse fallback and null-on-unreadable behavior
57
+ let looseParser = null;
58
+
59
+ export const setLooseExtension = (parser) => {
60
+ looseParser = parser;
61
+ };
62
+
63
+ export const requireLoose = (at = 'loose') => {
64
+ if (!looseParser) {
65
+ refuse('missingPlugin', at, {
66
+ explanation: isDevelopment
67
+ ? "import '@native-dates/plugins/loose' for { loose: true } and dayFirst"
68
+ : 0,
69
+ });
70
+ }
71
+ };
72
+
73
+ export const parseLoose = (text, zone, dayFirst) => {
74
+ requireLoose();
75
+ return looseParser(text, zone, dayFirst);
76
+ };
77
+
78
+ export const loosely = (build, codes = point) => {
79
+ requireLoose();
80
+ try {
81
+ return build();
82
+ }
83
+ catch (error) {
84
+ if (isUnreadable(error, codes)) {
85
+ return null;
86
+ }
87
+ throw error;
88
+ }
89
+ };