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/src/time.js ADDED
@@ -0,0 +1,357 @@
1
+ import { CalendarDate } from './calendar-date.js';
2
+ import { DateTime } from './date-time.js';
3
+ import { anchored } from './duration.js';
4
+ import { guard, loosely, parseLoose, refuse, refuseType, requireLoose } from './helpers/errors.js';
5
+ import { fieldsFrom, temporalDurationOf } from './helpers/fields.js';
6
+ import { formatIntl, intlOptions, unknownFormat } from './helpers/format.js';
7
+ import { IS_TIME } from './helpers/identity.js';
8
+ import { isDate, isDevelopment, isNumber, isPlainObject, isString } from './helpers/types.js';
9
+ import {
10
+ inspect,
11
+ isDigits,
12
+ isPlainDateTime,
13
+ isPlainTime,
14
+ isZonedDateTime,
15
+ singularKeys,
16
+ stepOf,
17
+ unit,
18
+ wholeNumbers,
19
+ withParts,
20
+ } from './helpers/units.js';
21
+ import { zoneId } from './helpers/zones.js';
22
+
23
+ const clock = /^(\d{1,2})(?::(\d{2}))?(?::(\d{2})(?:\.(\d{1,9}))?)?\s*([ap]\.?m\.?)?$/i;
24
+
25
+ export class Time {
26
+ // brand time
27
+ get [IS_TIME]() {
28
+ return true;
29
+ }
30
+ static [Symbol.hasInstance](value) {
31
+ return !!value?.[IS_TIME];
32
+ }
33
+
34
+ #plain;
35
+
36
+ // time(9, 30), time(input, zone), or time(input, { zone, loose })
37
+ constructor(input, minuteOrOptions, second) {
38
+ if (isDigits(input) && (isNumber(minuteOrOptions) || isDigits(minuteOrOptions))) {
39
+ [input, minuteOrOptions, second] = [
40
+ Number(input),
41
+ Number(minuteOrOptions),
42
+ second === undefined ? second : Number(second),
43
+ ];
44
+ }
45
+ const settings = isPlainObject(minuteOrOptions)
46
+ ? minuteOrOptions
47
+ : isString(minuteOrOptions)
48
+ ? { zone: minuteOrOptions }
49
+ : {};
50
+ this.#plain =
51
+ isPlainTime(input) && settings.zone === undefined && !settings.loose && settings.dayFirst === undefined
52
+ ? input
53
+ : Time.#read(input, isNumber(minuteOrOptions) ? minuteOrOptions : undefined, second, settings);
54
+ // the parts are own properties, so a value prints them in a console without a click. the text is
55
+ // keyed first so a preview leads with it, and filled once the parts it reads are in
56
+ this.text = undefined;
57
+ const plain = this.#plain;
58
+ this.hour = plain.hour;
59
+ this.minute = plain.minute;
60
+ this.second = plain.second;
61
+ this.millisecond = plain.millisecond;
62
+ this.text = this.format();
63
+ Object.freeze(this);
64
+ }
65
+
66
+ static #read(input, minute, second, settings) {
67
+ if (settings.zone !== undefined) {
68
+ zoneId(settings.zone);
69
+ }
70
+ if (settings.loose || settings.dayFirst !== undefined) {
71
+ requireLoose(settings.loose ? 'loose' : 'dayFirst');
72
+ }
73
+ if (input === undefined || input === null) {
74
+ return refuseType('notATime', String(input), {
75
+ explanation: isDevelopment
76
+ ? 'now().time is the current clock. a missing value is refused so an absent field never silently becomes now'
77
+ : 0,
78
+ });
79
+ }
80
+ if (isNumber(input)) {
81
+ return guard(
82
+ () => {
83
+ if (!wholeNumbers([input, minute, second])) {
84
+ throw new RangeError('a clock reads whole numbers');
85
+ }
86
+ return Temporal.PlainTime.from({ hour: input, minute: minute ?? 0, second: second ?? 0 }, {
87
+ overflow: 'reject',
88
+ });
89
+ },
90
+ 'unreadableTime',
91
+ `${input}:${minute ?? 0}`,
92
+ );
93
+ }
94
+ if (input instanceof Time) {
95
+ return input.toTemporal();
96
+ }
97
+ if (input instanceof DateTime) {
98
+ return input.toTemporal().toPlainTime();
99
+ }
100
+ if (isDate(input)) {
101
+ return new DateTime(input, settings).toTemporal().toPlainTime();
102
+ }
103
+ if (isString(input)) {
104
+ try {
105
+ return Time.#parse(input);
106
+ }
107
+ catch (error) {
108
+ if (!settings.loose || error.code !== 'unreadableTime') {
109
+ throw error;
110
+ }
111
+ const zoned = parseLoose(input, settings.zone, settings.dayFirst);
112
+ if (!zoned) {
113
+ throw error;
114
+ }
115
+ return zoned.toPlainTime();
116
+ }
117
+ }
118
+ if (isZonedDateTime(input) || isPlainDateTime(input)) {
119
+ return input.toPlainTime();
120
+ }
121
+ if (isPlainObject(input)) {
122
+ return guard(
123
+ () => Temporal.PlainTime.from(singularKeys(input), { overflow: 'reject' }),
124
+ 'unreadableTime',
125
+ JSON.stringify(input),
126
+ );
127
+ }
128
+ return refuseType('notATime', String(input), {
129
+ explanation: isDevelopment
130
+ ? "a time is a clock string like '09:00' or '5pm', numbers, a fields object, a datetime, or a Temporal value"
131
+ : 0,
132
+ });
133
+ }
134
+
135
+ // '9am', '5:30 pm', '09:00', '17:30:15'. anything else goes to Temporal's ISO reader
136
+ static #parse(text) {
137
+ const match = clock.exec(text.trim());
138
+ if (match) {
139
+ const [, hourText, minute = '0', second = '0', fraction = '', meridiem] = match;
140
+ let hour = Number(hourText);
141
+ if (meridiem) {
142
+ const afternoon = meridiem[0].toLowerCase() === 'p';
143
+ if (hour < 1 || hour > 12) {
144
+ refuse('unreadableTime', text, {
145
+ explanation: isDevelopment ? 'clock hours with am or pm run 1 through 12' : 0,
146
+ });
147
+ }
148
+ hour = (hour % 12) + (afternoon ? 12 : 0);
149
+ }
150
+ const nanos = fraction.padEnd(9, '0');
151
+ return guard(
152
+ () =>
153
+ Temporal.PlainTime.from({
154
+ hour,
155
+ minute: Number(minute),
156
+ second: Number(second),
157
+ millisecond: Number(nanos.slice(0, 3)),
158
+ microsecond: Number(nanos.slice(3, 6)),
159
+ nanosecond: Number(nanos.slice(6)),
160
+ }, { overflow: 'reject' }),
161
+ 'unreadableTime',
162
+ text,
163
+ );
164
+ }
165
+ return guard(
166
+ () => Temporal.PlainTime.from(text.trim()),
167
+ 'unreadableTime',
168
+ text,
169
+ isDevelopment
170
+ ? "write a clock time: '09:00', '5:30pm', '17:30:15.250'. plugins/loose adds { loose: true } to read a datetime and keep the clock"
171
+ : 0,
172
+ );
173
+ }
174
+
175
+ /*******************************
176
+ Reads
177
+ *******************************/
178
+
179
+ get microsecond() {
180
+ return this.#plain.microsecond;
181
+ }
182
+ get nanosecond() {
183
+ return this.#plain.nanosecond;
184
+ }
185
+
186
+ /*******************************
187
+ Arithmetic
188
+ *******************************/
189
+
190
+ // arithmetic wraps like a clock face: 23:00 plus two hours is 01:00
191
+ plus(amount, name) {
192
+ return new Time(
193
+ guard(() => this.#plain.add(Time.#clockFields(amount, name)), 'cannotAdd', `${amount} ${name ?? ''}`),
194
+ );
195
+ }
196
+
197
+ // a clock has no calendar, so a day added to a time is refused rather than wrapped away
198
+ static #clockFields(amount, name) {
199
+ const fields = fieldsFrom(amount, name);
200
+ const calendar = ['years', 'months', 'weeks', 'days'].find((field) => fields[field]);
201
+ if (calendar) {
202
+ refuse('notATimeUnit', calendar, {
203
+ explanation: isDevelopment
204
+ ? 'a time has no calendar. add hours or minutes, or place it on a date first: time.on(date, zone).plus(days)'
205
+ : 0,
206
+ });
207
+ }
208
+ return temporalDurationOf(fields);
209
+ }
210
+
211
+ minus(amount, name) {
212
+ return new Time(
213
+ guard(
214
+ () => this.#plain.subtract(Time.#clockFields(amount, name)),
215
+ 'cannotSubtract',
216
+ `${amount} ${name ?? ''}`,
217
+ ),
218
+ );
219
+ }
220
+
221
+ set(fields, value) {
222
+ const changes = isString(fields) ? { [unit(fields)]: value } : singularKeys(fields);
223
+ return new Time(
224
+ guard(() => withParts(this.#plain, changes), 'cannotSet', JSON.stringify(changes)),
225
+ );
226
+ }
227
+
228
+ startOf(name) {
229
+ return new Time(
230
+ guard(() => this.#plain.round({ smallestUnit: unit(name), roundingMode: 'floor' }), 'cannotRound', String(name)),
231
+ );
232
+ }
233
+
234
+ endOf(name) {
235
+ return this.startOf(name).plus(stepOf(unit(name))).minus({ milliseconds: 1 });
236
+ }
237
+
238
+ round(increment, name) {
239
+ return this.#round(increment, name, 'halfExpand');
240
+ }
241
+
242
+ floor(increment, name) {
243
+ return this.#round(increment, name, 'floor');
244
+ }
245
+
246
+ ceil(increment, name) {
247
+ return this.#round(increment, name, 'ceil');
248
+ }
249
+
250
+ #round(increment, name, mode) {
251
+ const [count, target] = isString(increment) && !isDigits(increment) ? [1, increment] : [Number(increment), name];
252
+ return new Time(
253
+ guard(
254
+ () => this.#plain.round({ smallestUnit: unit(target), roundingIncrement: count, roundingMode: mode }),
255
+ 'cannotRound',
256
+ `${count} ${target}`,
257
+ ),
258
+ );
259
+ }
260
+
261
+ /*******************************
262
+ Comparison
263
+ *******************************/
264
+
265
+ equals(other) {
266
+ return this.#compare(other) === 0;
267
+ }
268
+
269
+ #compare(other) {
270
+ return Temporal.PlainTime.compare(this.#plain, new Time(other).toTemporal());
271
+ }
272
+
273
+ isBefore(other) {
274
+ return this.#compare(other) < 0;
275
+ }
276
+
277
+ isAfter(other) {
278
+ return this.#compare(other) > 0;
279
+ }
280
+
281
+ isSame(other, name) {
282
+ if (name === undefined) {
283
+ return this.equals(other);
284
+ }
285
+ return this.startOf(name).equals(new Time(other).startOf(name));
286
+ }
287
+
288
+ /*******************************
289
+ Measure
290
+ *******************************/
291
+
292
+ // within the day, signed: 22:00 until 02:00 is minus twenty hours, not four
293
+ until(other, name) {
294
+ const span = anchored(this.#plain.until(new Time(other).toTemporal(), { largestUnit: 'hour' }), undefined);
295
+ return name === undefined ? span : span.total(name);
296
+ }
297
+
298
+ since(other, name) {
299
+ return new Time(other).until(this, name);
300
+ }
301
+
302
+ on(day, zone) {
303
+ return new CalendarDate(day).at(this, zone);
304
+ }
305
+
306
+ /*******************************
307
+ Output
308
+ *******************************/
309
+
310
+ format(spec, locale) {
311
+ const options = intlOptions('time', spec);
312
+ return options ? formatIntl('time', this.#plain, options, locale) : unknownFormat(spec);
313
+ }
314
+
315
+ // a fraction prints in groups of three, the width its precision needs, so one precision is one width
316
+ toString() {
317
+ const digits = this.nanosecond ? 9 : this.microsecond ? 6 : this.millisecond ? 3 : 0;
318
+ return this.#plain.toString({ fractionalSecondDigits: digits });
319
+ }
320
+
321
+ toJSON() {
322
+ return this.toString();
323
+ }
324
+
325
+ toTemporal() {
326
+ return this.#plain;
327
+ }
328
+
329
+ valueOf() {
330
+ return refuse('notANumber', String(this), {
331
+ explanation: isDevelopment
332
+ ? 'a time of day is not a point on the number line. compare with isBefore, isAfter or equals, or measure with until'
333
+ : 0,
334
+ });
335
+ }
336
+
337
+ // + gives the string, while < and - still refuse through valueOf
338
+ [Symbol.toPrimitive](hint) {
339
+ return hint === 'number' ? this.valueOf() : this.toString();
340
+ }
341
+
342
+ [inspect]() {
343
+ return `Time(${this.#plain})`;
344
+ }
345
+ }
346
+
347
+ Time.prototype.add = Time.prototype.plus;
348
+ Time.prototype.subtract = Time.prototype.minus;
349
+ Time.prototype.remove = Time.prototype.minus;
350
+
351
+ export const time = (input, minuteOrOptions, second) => {
352
+ const build = () =>
353
+ input instanceof Time && minuteOrOptions === undefined ? input : new Time(input, minuteOrOptions, second);
354
+ return isPlainObject(minuteOrOptions) && minuteOrOptions.loose ? loosely(build) : build();
355
+ };
356
+
357
+ export const isTime = (value) => value instanceof Time;