qrono 1.1.3 → 1.2.0

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/helpers.js CHANGED
@@ -28,14 +28,6 @@ export const millisecondsPerHour = secondsPerHour * millisecondsPerSecond
28
28
  export const millisecondsPerDay = secondsPerDay * millisecondsPerSecond
29
29
  export const millisecondsPerWeek = secondsPerWeek * millisecondsPerSecond
30
30
 
31
- export const monday = 1
32
- export const tuesday = 2
33
- export const wednesday = 3
34
- export const thursday = 4
35
- export const friday = 5
36
- export const saturday = 6
37
- export const sunday = 7
38
-
39
31
  export function has(object, ...keys) {
40
32
  return keys.flat().some(object.hasOwnProperty, object)
41
33
  }
@@ -85,67 +77,3 @@ export function hasDatetimeField(object) {
85
77
  'millisecond',
86
78
  ])
87
79
  }
88
-
89
- /**
90
- * Resolve a local time that falls on or near a DST transition boundary.
91
- *
92
- * Handles two distinct cases that arise when constructing a local Date:
93
- *
94
- * GAP (spring-forward, isGap = true):
95
- * A range of local times is skipped entirely. JavaScript automatically advances
96
- * the time to the next valid moment (post-transition / DST side), adding the gap
97
- * size to the UTC timestamp. The caller detects a gap by comparing the constructed
98
- * Date's local fields against the originally requested values.
99
- * - interpretAsDst = true → accept JS's forward-shift as-is (DST side)
100
- * - interpretAsDst = false → shift UTC back by the gap size (pre-transition side)
101
- *
102
- * OVERLAP (fall-back, isGap = false):
103
- * A range of local times occurs twice. JavaScript always picks the DST side
104
- * (first occurrence). If the time is not actually in an overlap, the adjustment
105
- * will not preserve the original local fields and the original Date is returned.
106
- * - interpretAsDst = true → accept JS's DST-side interpretation as-is
107
- * - interpretAsDst = false → shift UTC by the offset difference (standard-time side)
108
- *
109
- * In both cases the UTC adjustment uses the same formula:
110
- * adjustedUTC = date.getTime() + adjust * millisecondsPerMinute
111
- * where adjust = nextDay.timezoneOffset - prevDay.timezoneOffset.
112
- * For a gap the adjust is negative (offsets decrease going forward),
113
- * so subtracting it moves UTC backward to the pre-transition side.
114
- * For an overlap the adjust is also negative in the same direction,
115
- * and the same subtraction moves to the standard-time side.
116
- *
117
- * @param {boolean} interpretAsDst
118
- * @param {Date} date - The Date constructed from the requested local fields.
119
- * @param {boolean} isGap - true if the requested time fell in a DST gap (spring-forward).
120
- * @returns {Date}
121
- */
122
- export function resolveDstTime(interpretAsDst, date, isGap) {
123
- const numeric = date.getTime()
124
- const original = new Date(numeric)
125
- if (interpretAsDst) {
126
- return original
127
- }
128
- const nextDay = new Date(numeric)
129
- nextDay.setDate(date.getDate() + 1)
130
- const prevDay = new Date(numeric)
131
- prevDay.setDate(date.getDate() - 1)
132
- const adjust = nextDay.getTimezoneOffset() - prevDay.getTimezoneOffset()
133
- if (adjust === 0) {
134
- return original
135
- }
136
- const adjustedUTC = new Date(
137
- new Date(numeric).setUTCMinutes(date.getUTCMinutes() + adjust)
138
- )
139
- if (isGap) {
140
- return adjustedUTC
141
- }
142
- // For an overlap, verify the candidate preserves the original local fields.
143
- // If it does not, the time is not actually in an overlap — return as-is.
144
- if (
145
- adjustedUTC.getHours() !== date.getHours() ||
146
- adjustedUTC.getMinutes() !== date.getMinutes()
147
- ) {
148
- return original
149
- }
150
- return adjustedUTC
151
- }
package/src/qrono.js CHANGED
@@ -6,7 +6,6 @@ import {
6
6
  isObject,
7
7
  isString,
8
8
  isValidDate,
9
- resolveDstTime,
10
9
  hasDatetimeField,
11
10
  initialSafeDate,
12
11
  daysPerWeek,
@@ -14,13 +13,6 @@ import {
14
13
  minutesPerHour,
15
14
  millisecondsPerMinute,
16
15
  millisecondsPerDay,
17
- monday,
18
- tuesday,
19
- wednesday,
20
- thursday,
21
- friday,
22
- saturday,
23
- sunday,
24
16
  } from './helpers.js'
25
17
 
26
18
  // -----------------------------------------------------------------------------
@@ -30,16 +22,6 @@ const qrono = Qrono
30
22
 
31
23
  export { qrono }
32
24
 
33
- export {
34
- monday,
35
- tuesday,
36
- wednesday,
37
- thursday,
38
- friday,
39
- saturday,
40
- sunday,
41
- } from './helpers'
42
-
43
25
  // -----------------------------------------------------------------------------
44
26
  // Static
45
27
  // -----------------------------------------------------------------------------
@@ -48,7 +30,7 @@ Qrono.date = QronoDate
48
30
  // NOTE Must be flat object for shallow cloning.
49
31
  const defaultContext = {
50
32
  localtime: false,
51
- interpretAsDst: true,
33
+ disambiguation: 'compatible',
52
34
  }
53
35
 
54
36
  for (const key of fields(defaultContext)) {
@@ -84,6 +66,14 @@ Qrono.asLocaltime = function () {
84
66
  return this
85
67
  }
86
68
 
69
+ const monday = 1
70
+ const tuesday = 2
71
+ const wednesday = 3
72
+ const thursday = 4
73
+ const friday = 5
74
+ const saturday = 6
75
+ const sunday = 7
76
+
87
77
  Object.assign(Qrono, {
88
78
  monday,
89
79
  tuesday,
@@ -97,7 +87,7 @@ Object.assign(Qrono, {
97
87
  // -----------------------------------------------------------------------------
98
88
  // Constructor
99
89
  // -----------------------------------------------------------------------------
100
- const internal = Symbol('Qrono.internal')
90
+ const internal = Symbol()
101
91
 
102
92
  function Qrono(...args) {
103
93
  if (!new.target) {
@@ -107,7 +97,7 @@ function Qrono(...args) {
107
97
  // properties
108
98
  nativeDate: null,
109
99
  localtime: false,
110
- interpretAsDst: false,
100
+ disambiguation: 'compatible',
111
101
  // methods
112
102
  set,
113
103
  parse,
@@ -147,8 +137,9 @@ function Qrono(...args) {
147
137
  } else if (Number.isFinite(first) && !Number.isFinite(second)) {
148
138
  self.nativeDate = new Date(first)
149
139
  } else if (Number.isFinite(first) || Array.isArray(first)) {
150
- const values = args.flat().filter(v => Number.isSafeInteger(v))
151
- if (values.length !== args.flat().length) {
140
+ const flat = args.flat()
141
+ const values = flat.filter(v => Number.isSafeInteger(v))
142
+ if (values.length !== flat.length) {
152
143
  throw RangeError('Should be safe integers')
153
144
  }
154
145
  if (values.length > 7) {
@@ -195,43 +186,7 @@ function getNative(name) {
195
186
  function set(values) {
196
187
  const args = { ...values }
197
188
  args.month = args.month && args.month - 1
198
- if (this.localtime) {
199
- const dateOnly = !has(values, 'hour', 'minute', 'second', 'millisecond')
200
- const interpretAsDst = dateOnly ? true : this.interpretAsDst
201
- const baseDate = this.nativeDate ?? new Date(0, 0)
202
- const newDate = new Date(initialSafeDate.getTime())
203
- const requested = {
204
- year: args.year ?? baseDate.getFullYear(),
205
- month: args.month ?? baseDate.getMonth(),
206
- day: args.day ?? baseDate.getDate(),
207
- hour: args.hour ?? (dateOnly ? 0 : baseDate.getHours()),
208
- minute: args.minute ?? (dateOnly ? 0 : baseDate.getMinutes()),
209
- second: args.second ?? (dateOnly ? 0 : baseDate.getSeconds()),
210
- millisecond:
211
- args.millisecond ?? (dateOnly ? 0 : baseDate.getMilliseconds()),
212
- }
213
- newDate.setFullYear(requested.year, requested.month, requested.day)
214
- newDate.setHours(
215
- requested.hour,
216
- requested.minute,
217
- requested.second,
218
- requested.millisecond
219
- )
220
- // Detect whether the constructed Date landed in a DST gap (missing time).
221
- // In a gap, JavaScript silently shifts the time forward.
222
- const isGap =
223
- requested.year * 1e8 +
224
- requested.month * 1e6 +
225
- requested.day * 1e4 +
226
- requested.hour * 1e2 +
227
- requested.minute <
228
- newDate.getFullYear() * 1e8 +
229
- newDate.getMonth() * 1e6 +
230
- newDate.getDate() * 1e4 +
231
- newDate.getHours() * 1e2 +
232
- newDate.getMinutes()
233
- this.nativeDate = resolveDstTime(interpretAsDst, newDate, isGap)
234
- } else {
189
+ if (!this.localtime) {
235
190
  const baseDate = this.nativeDate ?? new Date(0)
236
191
  const newDate = new Date(0)
237
192
  newDate.setUTCFullYear(
@@ -246,7 +201,71 @@ function set(values) {
246
201
  args.millisecond ?? baseDate.getUTCMilliseconds()
247
202
  )
248
203
  this.nativeDate = newDate
204
+ return this
205
+ }
206
+ const dateOnly = !has(values, 'hour', 'minute', 'second', 'millisecond')
207
+ const disambig = dateOnly ? 'later' : this.disambiguation
208
+ const baseDate = this.nativeDate ?? new Date(0, 0)
209
+ const newDate = new Date(initialSafeDate.getTime())
210
+ const requested = {
211
+ year: args.year ?? baseDate.getFullYear(),
212
+ month: args.month ?? baseDate.getMonth(),
213
+ day: args.day ?? baseDate.getDate(),
214
+ hour: args.hour ?? (dateOnly ? 0 : baseDate.getHours()),
215
+ minute: args.minute ?? (dateOnly ? 0 : baseDate.getMinutes()),
216
+ second: args.second ?? (dateOnly ? 0 : baseDate.getSeconds()),
217
+ millisecond:
218
+ args.millisecond ?? (dateOnly ? 0 : baseDate.getMilliseconds()),
219
+ }
220
+ newDate.setFullYear(requested.year, requested.month, requested.day)
221
+ newDate.setHours(
222
+ requested.hour,
223
+ requested.minute,
224
+ requested.second,
225
+ requested.millisecond
226
+ )
227
+ // Compute the offset-delta between the day before and after.
228
+ const numeric = newDate.getTime()
229
+ const nextDay = new Date(numeric)
230
+ nextDay.setDate(newDate.getDate() + 1)
231
+ const prevDay = new Date(numeric)
232
+ prevDay.setDate(newDate.getDate() - 1)
233
+ const adjust = nextDay.getTimezoneOffset() - prevDay.getTimezoneOffset()
234
+ // No DST transition nearby — nothing to resolve.
235
+ if (disambig === 'compatible' || adjust === 0) {
236
+ this.nativeDate = newDate
237
+ return this
238
+ }
239
+ // Detect whether the constructed Date landed in a DST gap (missing time).
240
+ // In a gap, JavaScript silently shifts the time forward.
241
+ const isGap =
242
+ requested.year * 1e8 +
243
+ requested.month * 1e6 +
244
+ requested.day * 1e4 +
245
+ requested.hour * 1e2 +
246
+ requested.minute <
247
+ newDate.getFullYear() * 1e8 +
248
+ newDate.getMonth() * 1e6 +
249
+ newDate.getDate() * 1e4 +
250
+ newDate.getHours() * 1e2 +
251
+ newDate.getMinutes()
252
+ // Compute the standard-time candidate.
253
+ // Verify the candidate actually preserves the original local fields.
254
+ // If it doesn't, the time isn't truly in an overlap.
255
+ const adjustedUTC = new Date(
256
+ new Date(numeric).setUTCMinutes(newDate.getUTCMinutes() + adjust)
257
+ )
258
+ const isOverlap =
259
+ adjustedUTC.getHours() === newDate.getHours() &&
260
+ adjustedUTC.getMinutes() === newDate.getMinutes()
261
+ if (!isGap && !isOverlap) {
262
+ this.nativeDate = newDate
263
+ return this
249
264
  }
265
+ if (disambig === 'reject') {
266
+ throw new RangeError(`Requested local time ${requested} is ambiguous.`)
267
+ }
268
+ this.nativeDate = disambig === 'later' ? newDate : adjustedUTC
250
269
  return this
251
270
  }
252
271
 
@@ -303,24 +322,26 @@ function parse(str) {
303
322
  // -----------------------------------------------------------------------------
304
323
  // Public methods
305
324
  // -----------------------------------------------------------------------------
325
+ const p = (v, n) => String(v).padStart(n, '0')
326
+
306
327
  // Basic
307
328
  Qrono.prototype.toString = function () {
308
329
  if (this[internal].localtime) {
309
330
  const t = this[internal].nativeDate
310
331
  const offset = -t.getTimezoneOffset()
311
332
  const offsetAbs = Math.abs(offset)
312
- return `${String(t.getFullYear()).padStart(4, '0')}-${String(
313
- t.getMonth() + 1
314
- ).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}T${String(
315
- t.getHours()
316
- ).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}:${String(
317
- t.getSeconds()
318
- ).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}${
319
- (offset >= 0 ? '+' : '-') +
320
- String(Math.trunc(offsetAbs / minutesPerHour)).padStart(2, '0') +
321
- ':' +
322
- String(offsetAbs % minutesPerHour).padStart(2, '0')
323
- }`
333
+ return (
334
+ `${p(t.getFullYear(), 4)}-` +
335
+ `${p(t.getMonth() + 1, 2)}-` +
336
+ `${p(t.getDate(), 2)}T` +
337
+ `${p(t.getHours(), 2)}:` +
338
+ `${p(t.getMinutes(), 2)}:` +
339
+ `${p(t.getSeconds(), 2)}.` +
340
+ `${p(t.getMilliseconds(), 3)}` +
341
+ `${offset >= 0 ? '+' : '-'}` +
342
+ `${p(Math.trunc(offsetAbs / minutesPerHour), 2)}:` +
343
+ `${p(offsetAbs % minutesPerHour, 2)}`
344
+ )
324
345
  }
325
346
  return this[internal].nativeDate.toISOString()
326
347
  }
@@ -338,7 +359,7 @@ Qrono.prototype.context = function (context) {
338
359
  ? this.clone(context)
339
360
  : {
340
361
  localtime: this[internal].localtime,
341
- interpretAsDst: this[internal].interpretAsDst,
362
+ disambiguation: this[internal].disambiguation,
342
363
  }
343
364
  }
344
365
 
@@ -356,10 +377,10 @@ Qrono.prototype.localtime = function (arg) {
356
377
  return given(arg) ? this.clone({ localtime: arg }) : this[internal].localtime
357
378
  }
358
379
 
359
- Qrono.prototype.interpretAsDst = function (arg) {
380
+ Qrono.prototype.disambiguation = function (arg) {
360
381
  return given(arg)
361
- ? this.clone({ interpretAsDst: arg })
362
- : this[internal].interpretAsDst
382
+ ? this.clone({ disambiguation: arg })
383
+ : this[internal].disambiguation
363
384
  }
364
385
 
365
386
  Qrono.prototype.valid = function () {
@@ -409,46 +430,20 @@ Qrono.prototype.asLocaltime = function () {
409
430
  }
410
431
 
411
432
  // Accessor
412
- Qrono.prototype.year = function (value) {
413
- return given(value)
414
- ? this.clone({ year: value })
415
- : this[internal].getNative('FullYear')
416
- }
417
-
418
- Qrono.prototype.month = function (value) {
419
- return given(value)
420
- ? this.clone({ month: value })
421
- : this[internal].getNative('Month') + 1
422
- }
423
-
424
- Qrono.prototype.day = function (value) {
425
- return given(value)
426
- ? this.clone({ day: value })
427
- : this[internal].getNative('Date')
428
- }
429
-
430
- Qrono.prototype.hour = function (value) {
431
- return given(value)
432
- ? this.clone({ hour: value })
433
- : this[internal].getNative('Hours')
434
- }
435
-
436
- Qrono.prototype.minute = function (value) {
437
- return given(value)
438
- ? this.clone({ minute: value })
439
- : this[internal].getNative('Minutes')
440
- }
441
-
442
- Qrono.prototype.second = function (value) {
443
- return given(value)
444
- ? this.clone({ second: value })
445
- : this[internal].getNative('Seconds')
446
- }
447
-
448
- Qrono.prototype.millisecond = function (value) {
449
- return given(value)
450
- ? this.clone({ millisecond: value })
451
- : this[internal].getNative('Milliseconds')
433
+ for (const [field, native, offset] of [
434
+ ['year', 'FullYear', 0],
435
+ ['month', 'Month', 1],
436
+ ['day', 'Date', 0],
437
+ ['hour', 'Hours', 0],
438
+ ['minute', 'Minutes', 0],
439
+ ['second', 'Seconds', 0],
440
+ ['millisecond', 'Milliseconds', 0],
441
+ ]) {
442
+ Qrono.prototype[field] = function (value) {
443
+ return given(value)
444
+ ? this.clone({ [field]: value })
445
+ : this[internal].getNative(native) + offset
446
+ }
452
447
  }
453
448
 
454
449
  // Getter
@@ -517,7 +512,7 @@ Qrono.prototype.minutesInDay = function () {
517
512
  if (!this[internal].localtime) {
518
513
  return minutesPerDay
519
514
  }
520
- const startOfDay = this.context({ interpretAsDst: true }).startOfDay()
515
+ const startOfDay = this.context({ disambiguation: 'later' }).startOfDay()
521
516
  const nextDay = startOfDay.plus({ day: 1 }).startOfDay()
522
517
  if (startOfDay.day() === nextDay.day()) {
523
518
  return minutesPerDay
@@ -547,41 +542,26 @@ Qrono.prototype.weeksInYear = function () {
547
542
  return 52
548
543
  }
549
544
 
550
- Qrono.prototype.startOfYear = function () {
551
- return this.clone({
552
- month: 1,
553
- day: 1,
554
- hour: 0,
555
- minute: 0,
556
- second: 0,
557
- millisecond: 0,
558
- })
559
- }
560
-
561
- Qrono.prototype.startOfMonth = function () {
562
- return this.clone({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 })
545
+ for (const [name, cloneArg] of [
546
+ ['Year', { month: 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }],
547
+ ['Month', { day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }],
548
+ ['Hour', { minute: 0, second: 0, millisecond: 0 }],
549
+ ['Minute', { second: 0, millisecond: 0 }],
550
+ ['Second', { millisecond: 0 }],
551
+ ]) {
552
+ Qrono.prototype[`startOf${name}`] = function () {
553
+ return this.clone(cloneArg)
554
+ }
563
555
  }
564
556
 
565
557
  Qrono.prototype.startOfDay = function () {
566
558
  const timestamp = this.clone(
567
- { interpretAsDst: true },
559
+ { disambiguation: 'later' },
568
560
  { hour: 0, minute: 0, second: 0, millisecond: 0 }
569
561
  ).numeric()
570
562
  return this.clone(timestamp)
571
563
  }
572
564
 
573
- Qrono.prototype.startOfHour = function () {
574
- return this.clone({ minute: 0, second: 0, millisecond: 0 })
575
- }
576
-
577
- Qrono.prototype.startOfMinute = function () {
578
- return this.clone({ second: 0, millisecond: 0 })
579
- }
580
-
581
- Qrono.prototype.startOfSecond = function () {
582
- return this.clone({ millisecond: 0 })
583
- }
584
-
585
565
  Qrono.prototype.isSame = function (another) {
586
566
  return +this === +another
587
567
  }
@@ -626,8 +606,9 @@ function plus(sign, ...args) {
626
606
  }
627
607
  timeFields = arg0
628
608
  } else if (Number.isFinite(arg0) || Array.isArray(arg0)) {
629
- const values = args.flat().filter(v => Number.isSafeInteger(v))
630
- if (values.length !== args.flat().length) {
609
+ const flat = args.flat()
610
+ const values = flat.filter(v => Number.isSafeInteger(v))
611
+ if (values.length !== flat.length) {
631
612
  throw RangeError('Should be safe integers')
632
613
  }
633
614
  if (values.length > 7) {
@@ -681,7 +662,7 @@ function plus(sign, ...args) {
681
662
  // -----------------------------------------------------------------------------
682
663
  // QronoDate Class
683
664
  // -----------------------------------------------------------------------------
684
- const internalDate = Symbol('QronoDate.internal')
665
+ const internalDate = Symbol()
685
666
 
686
667
  function QronoDate(...args) {
687
668
  if (!new.target) {
@@ -781,7 +762,7 @@ for (const method of [
781
762
  for (const method of ['minutesInDay', 'hasDstInYear', 'isDstTransitionDay']) {
782
763
  QronoDate.prototype[method] = function () {
783
764
  return qrono(
784
- { interpretAsDst: true },
765
+ { disambiguation: 'later' },
785
766
  this[internalDate].datetime.toArray().slice(0, 3)
786
767
  )[method]()
787
768
  }
package/types/qrono.d.ts CHANGED
@@ -230,16 +230,16 @@ declare namespace qrono {
230
230
  export function localtime(value: boolean): typeof qrono
231
231
 
232
232
  /**
233
- * Returns whether interpretAsDst is enabled.
233
+ * Returns the current global disambiguation option.
234
234
  */
235
- export function interpretAsDst(): boolean
235
+ export function disambiguation(): Disambiguation
236
236
 
237
237
  /**
238
- * Sets whether interpretAsDst is enabled.
239
- * @param value - True to enable interpretAsDst.
238
+ * Sets the global disambiguation option.
239
+ * @param value - The disambiguation option to set.
240
240
  * @returns The qrono object.
241
241
  */
242
- export function interpretAsDst(value: boolean): typeof qrono
242
+ export function disambiguation(value: Disambiguation): typeof qrono
243
243
 
244
244
  /**
245
245
  * Returns new qrono instance with UTC context.
@@ -251,9 +251,20 @@ declare namespace qrono {
251
251
  */
252
252
  export function asLocaltime(): typeof qrono
253
253
 
254
+ /**
255
+ * The four disambiguation options for resolving ambiguous local times,
256
+ * mirroring the Temporal API.
257
+ *
258
+ * - `'compatible'`: Gap → later (JS native forward-shift). Overlap → earlier (standard-time side).
259
+ * - `'earlier'`: Gap or overlap → the earlier (standard-time / pre-transition) instant.
260
+ * - `'later'`: Gap or overlap → the later (DST side / post-transition) instant.
261
+ * - `'reject'`: Throws `RangeError` if the time falls in a gap or overlap.
262
+ */
263
+ export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject'
264
+
254
265
  export type Context = {
255
266
  localtime?: boolean
256
- interpretAsDst?: boolean
267
+ disambiguation?: Disambiguation
257
268
  }
258
269
 
259
270
  export type TimeFields = {
@@ -322,16 +333,16 @@ declare namespace qrono {
322
333
  localtime(yes: boolean): Qrono
323
334
 
324
335
  /**
325
- * Returns whether interpretAsDst is enabled for the Qrono instance.
336
+ * Returns the current disambiguation option of the Qrono instance.
326
337
  */
327
- interpretAsDst(): boolean
338
+ disambiguation(): Disambiguation
328
339
 
329
340
  /**
330
- * Sets whether interpretAsDst is enabled for the Qrono instance.
331
- * @param yes - True to enable interpretAsDst, false to disable.
332
- * @returns The Qrono instance.
341
+ * Sets the disambiguation option of the Qrono instance.
342
+ * @param value - The disambiguation option.
343
+ * @returns A new Qrono instance with the updated disambiguation.
333
344
  */
334
- interpretAsDst(yes: boolean): Qrono
345
+ disambiguation(value: Disambiguation): Qrono
335
346
 
336
347
  /**
337
348
  * Returns whether the Qrono instance is valid.