UNPKG

19.5 kBJavaScriptView Raw
1import DateTime, { friendlyDateTime } from "./datetime.js";
2import Duration, { friendlyDuration } from "./duration.js";
3import Settings from "./settings.js";
4import { InvalidArgumentError, InvalidIntervalError } from "./errors.js";
5import Invalid from "./impl/invalid.js";
6
7const INVALID = "Invalid Interval";
8
9// checks if the start is equal to or before the end
10function validateStartEnd(start, end) {
11 if (!start || !start.isValid) {
12 return Interval.invalid("missing or invalid start");
13 } else if (!end || !end.isValid) {
14 return Interval.invalid("missing or invalid end");
15 } else if (end < start) {
16 return Interval.invalid(
17 "end before start",
18 `The end of an interval must be after its start, but you had start=${start.toISO()} and end=${end.toISO()}`
19 );
20 } else {
21 return null;
22 }
23}
24
25/**
26 * An Interval object represents a half-open interval of time, where each endpoint is a {@link DateTime}. Conceptually, it's a container for those two endpoints, accompanied by methods for creating, parsing, interrogating, comparing, transforming, and formatting them.
27 *
28 * Here is a brief overview of the most commonly used methods and getters in Interval:
29 *
30 * * **Creation** To create an Interval, use {@link Interval.fromDateTimes}, {@link Interval.after}, {@link Interval.before}, or {@link Interval.fromISO}.
31 * * **Accessors** Use {@link Interval#start} and {@link Interval#end} to get the start and end.
32 * * **Interrogation** To analyze the Interval, use {@link Interval#count}, {@link Interval#length}, {@link Interval#hasSame}, {@link Interval#contains}, {@link Interval#isAfter}, or {@link Interval#isBefore}.
33 * * **Transformation** To create other Intervals out of this one, use {@link Interval#set}, {@link Interval#splitAt}, {@link Interval#splitBy}, {@link Interval#divideEqually}, {@link Interval#merge}, {@link Interval#xor}, {@link Interval#union}, {@link Interval#intersection}, or {@link Interval#difference}.
34 * * **Comparison** To compare this Interval to another one, use {@link Interval#equals}, {@link Interval#overlaps}, {@link Interval#abutsStart}, {@link Interval#abutsEnd}, {@link Interval#engulfs}
35 * * **Output** To convert the Interval into other representations, see {@link Interval#toString}, {@link Interval#toISO}, {@link Interval#toISODate}, {@link Interval#toISOTime}, {@link Interval#toFormat}, and {@link Interval#toDuration}.
36 */
37export default class Interval {
38 /**
39 * @private
40 */
41 constructor(config) {
42 /**
43 * @access private
44 */
45 this.s = config.start;
46 /**
47 * @access private
48 */
49 this.e = config.end;
50 /**
51 * @access private
52 */
53 this.invalid = config.invalid || null;
54 /**
55 * @access private
56 */
57 this.isLuxonInterval = true;
58 }
59
60 /**
61 * Create an invalid Interval.
62 * @param {string} reason - simple string of why this Interval is invalid. Should not contain parameters or anything else data-dependent
63 * @param {string} [explanation=null] - longer explanation, may include parameters and other useful debugging information
64 * @return {Interval}
65 */
66 static invalid(reason, explanation = null) {
67 if (!reason) {
68 throw new InvalidArgumentError("need to specify a reason the Interval is invalid");
69 }
70
71 const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
72
73 if (Settings.throwOnInvalid) {
74 throw new InvalidIntervalError(invalid);
75 } else {
76 return new Interval({ invalid });
77 }
78 }
79
80 /**
81 * Create an Interval from a start DateTime and an end DateTime. Inclusive of the start but not the end.
82 * @param {DateTime|Date|Object} start
83 * @param {DateTime|Date|Object} end
84 * @return {Interval}
85 */
86 static fromDateTimes(start, end) {
87 const builtStart = friendlyDateTime(start),
88 builtEnd = friendlyDateTime(end);
89
90 const validateError = validateStartEnd(builtStart, builtEnd);
91
92 if (validateError == null) {
93 return new Interval({
94 start: builtStart,
95 end: builtEnd,
96 });
97 } else {
98 return validateError;
99 }
100 }
101
102 /**
103 * Create an Interval from a start DateTime and a Duration to extend to.
104 * @param {DateTime|Date|Object} start
105 * @param {Duration|Object|number} duration - the length of the Interval.
106 * @return {Interval}
107 */
108 static after(start, duration) {
109 const dur = friendlyDuration(duration),
110 dt = friendlyDateTime(start);
111 return Interval.fromDateTimes(dt, dt.plus(dur));
112 }
113
114 /**
115 * Create an Interval from an end DateTime and a Duration to extend backwards to.
116 * @param {DateTime|Date|Object} end
117 * @param {Duration|Object|number} duration - the length of the Interval.
118 * @return {Interval}
119 */
120 static before(end, duration) {
121 const dur = friendlyDuration(duration),
122 dt = friendlyDateTime(end);
123 return Interval.fromDateTimes(dt.minus(dur), dt);
124 }
125
126 /**
127 * Create an Interval from an ISO 8601 string.
128 * Accepts `<start>/<end>`, `<start>/<duration>`, and `<duration>/<end>` formats.
129 * @param {string} text - the ISO string to parse
130 * @param {Object} [opts] - options to pass {@link DateTime.fromISO} and optionally {@link Duration.fromISO}
131 * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
132 * @return {Interval}
133 */
134 static fromISO(text, opts) {
135 const [s, e] = (text || "").split("/", 2);
136 if (s && e) {
137 let start, startIsValid;
138 try {
139 start = DateTime.fromISO(s, opts);
140 startIsValid = start.isValid;
141 } catch (e) {
142 startIsValid = false;
143 }
144
145 let end, endIsValid;
146 try {
147 end = DateTime.fromISO(e, opts);
148 endIsValid = end.isValid;
149 } catch (e) {
150 endIsValid = false;
151 }
152
153 if (startIsValid && endIsValid) {
154 return Interval.fromDateTimes(start, end);
155 }
156
157 if (startIsValid) {
158 const dur = Duration.fromISO(e, opts);
159 if (dur.isValid) {
160 return Interval.after(start, dur);
161 }
162 } else if (endIsValid) {
163 const dur = Duration.fromISO(s, opts);
164 if (dur.isValid) {
165 return Interval.before(end, dur);
166 }
167 }
168 }
169 return Interval.invalid("unparsable", `the input "${text}" can't be parsed as ISO 8601`);
170 }
171
172 /**
173 * Check if an object is an Interval. Works across context boundaries
174 * @param {object} o
175 * @return {boolean}
176 */
177 static isInterval(o) {
178 return (o && o.isLuxonInterval) || false;
179 }
180
181 /**
182 * Returns the start of the Interval
183 * @type {DateTime}
184 */
185 get start() {
186 return this.isValid ? this.s : null;
187 }
188
189 /**
190 * Returns the end of the Interval
191 * @type {DateTime}
192 */
193 get end() {
194 return this.isValid ? this.e : null;
195 }
196
197 /**
198 * Returns whether this Interval's end is at least its start, meaning that the Interval isn't 'backwards'.
199 * @type {boolean}
200 */
201 get isValid() {
202 return this.invalidReason === null;
203 }
204
205 /**
206 * Returns an error code if this Interval is invalid, or null if the Interval is valid
207 * @type {string}
208 */
209 get invalidReason() {
210 return this.invalid ? this.invalid.reason : null;
211 }
212
213 /**
214 * Returns an explanation of why this Interval became invalid, or null if the Interval is valid
215 * @type {string}
216 */
217 get invalidExplanation() {
218 return this.invalid ? this.invalid.explanation : null;
219 }
220
221 /**
222 * Returns the length of the Interval in the specified unit.
223 * @param {string} unit - the unit (such as 'hours' or 'days') to return the length in.
224 * @return {number}
225 */
226 length(unit = "milliseconds") {
227 return this.isValid ? this.toDuration(...[unit]).get(unit) : NaN;
228 }
229
230 /**
231 * Returns the count of minutes, hours, days, months, or years included in the Interval, even in part.
232 * Unlike {@link Interval#length} this counts sections of the calendar, not periods of time, e.g. specifying 'day'
233 * asks 'what dates are included in this interval?', not 'how many days long is this interval?'
234 * @param {string} [unit='milliseconds'] - the unit of time to count.
235 * @return {number}
236 */
237 count(unit = "milliseconds") {
238 if (!this.isValid) return NaN;
239 const start = this.start.startOf(unit),
240 end = this.end.startOf(unit);
241 return Math.floor(end.diff(start, unit).get(unit)) + 1;
242 }
243
244 /**
245 * Returns whether this Interval's start and end are both in the same unit of time
246 * @param {string} unit - the unit of time to check sameness on
247 * @return {boolean}
248 */
249 hasSame(unit) {
250 return this.isValid ? this.isEmpty() || this.e.minus(1).hasSame(this.s, unit) : false;
251 }
252
253 /**
254 * Return whether this Interval has the same start and end DateTimes.
255 * @return {boolean}
256 */
257 isEmpty() {
258 return this.s.valueOf() === this.e.valueOf();
259 }
260
261 /**
262 * Return whether this Interval's start is after the specified DateTime.
263 * @param {DateTime} dateTime
264 * @return {boolean}
265 */
266 isAfter(dateTime) {
267 if (!this.isValid) return false;
268 return this.s > dateTime;
269 }
270
271 /**
272 * Return whether this Interval's end is before the specified DateTime.
273 * @param {DateTime} dateTime
274 * @return {boolean}
275 */
276 isBefore(dateTime) {
277 if (!this.isValid) return false;
278 return this.e <= dateTime;
279 }
280
281 /**
282 * Return whether this Interval contains the specified DateTime.
283 * @param {DateTime} dateTime
284 * @return {boolean}
285 */
286 contains(dateTime) {
287 if (!this.isValid) return false;
288 return this.s <= dateTime && this.e > dateTime;
289 }
290
291 /**
292 * "Sets" the start and/or end dates. Returns a newly-constructed Interval.
293 * @param {Object} values - the values to set
294 * @param {DateTime} values.start - the starting DateTime
295 * @param {DateTime} values.end - the ending DateTime
296 * @return {Interval}
297 */
298 set({ start, end } = {}) {
299 if (!this.isValid) return this;
300 return Interval.fromDateTimes(start || this.s, end || this.e);
301 }
302
303 /**
304 * Split this Interval at each of the specified DateTimes
305 * @param {...DateTime} dateTimes - the unit of time to count.
306 * @return {Array}
307 */
308 splitAt(...dateTimes) {
309 if (!this.isValid) return [];
310 const sorted = dateTimes
311 .map(friendlyDateTime)
312 .filter((d) => this.contains(d))
313 .sort(),
314 results = [];
315 let { s } = this,
316 i = 0;
317
318 while (s < this.e) {
319 const added = sorted[i] || this.e,
320 next = +added > +this.e ? this.e : added;
321 results.push(Interval.fromDateTimes(s, next));
322 s = next;
323 i += 1;
324 }
325
326 return results;
327 }
328
329 /**
330 * Split this Interval into smaller Intervals, each of the specified length.
331 * Left over time is grouped into a smaller interval
332 * @param {Duration|Object|number} duration - The length of each resulting interval.
333 * @return {Array}
334 */
335 splitBy(duration) {
336 const dur = friendlyDuration(duration);
337
338 if (!this.isValid || !dur.isValid || dur.as("milliseconds") === 0) {
339 return [];
340 }
341
342 let { s } = this,
343 idx = 1,
344 next;
345
346 const results = [];
347 while (s < this.e) {
348 const added = this.start.plus(dur.mapUnits((x) => x * idx));
349 next = +added > +this.e ? this.e : added;
350 results.push(Interval.fromDateTimes(s, next));
351 s = next;
352 idx += 1;
353 }
354
355 return results;
356 }
357
358 /**
359 * Split this Interval into the specified number of smaller intervals.
360 * @param {number} numberOfParts - The number of Intervals to divide the Interval into.
361 * @return {Array}
362 */
363 divideEqually(numberOfParts) {
364 if (!this.isValid) return [];
365 return this.splitBy(this.length() / numberOfParts).slice(0, numberOfParts);
366 }
367
368 /**
369 * Return whether this Interval overlaps with the specified Interval
370 * @param {Interval} other
371 * @return {boolean}
372 */
373 overlaps(other) {
374 return this.e > other.s && this.s < other.e;
375 }
376
377 /**
378 * Return whether this Interval's end is adjacent to the specified Interval's start.
379 * @param {Interval} other
380 * @return {boolean}
381 */
382 abutsStart(other) {
383 if (!this.isValid) return false;
384 return +this.e === +other.s;
385 }
386
387 /**
388 * Return whether this Interval's start is adjacent to the specified Interval's end.
389 * @param {Interval} other
390 * @return {boolean}
391 */
392 abutsEnd(other) {
393 if (!this.isValid) return false;
394 return +other.e === +this.s;
395 }
396
397 /**
398 * Return whether this Interval engulfs the start and end of the specified Interval.
399 * @param {Interval} other
400 * @return {boolean}
401 */
402 engulfs(other) {
403 if (!this.isValid) return false;
404 return this.s <= other.s && this.e >= other.e;
405 }
406
407 /**
408 * Return whether this Interval has the same start and end as the specified Interval.
409 * @param {Interval} other
410 * @return {boolean}
411 */
412 equals(other) {
413 if (!this.isValid || !other.isValid) {
414 return false;
415 }
416
417 return this.s.equals(other.s) && this.e.equals(other.e);
418 }
419
420 /**
421 * Return an Interval representing the intersection of this Interval and the specified Interval.
422 * Specifically, the resulting Interval has the maximum start time and the minimum end time of the two Intervals.
423 * Returns null if the intersection is empty, meaning, the intervals don't intersect.
424 * @param {Interval} other
425 * @return {Interval}
426 */
427 intersection(other) {
428 if (!this.isValid) return this;
429 const s = this.s > other.s ? this.s : other.s,
430 e = this.e < other.e ? this.e : other.e;
431
432 if (s >= e) {
433 return null;
434 } else {
435 return Interval.fromDateTimes(s, e);
436 }
437 }
438
439 /**
440 * Return an Interval representing the union of this Interval and the specified Interval.
441 * Specifically, the resulting Interval has the minimum start time and the maximum end time of the two Intervals.
442 * @param {Interval} other
443 * @return {Interval}
444 */
445 union(other) {
446 if (!this.isValid) return this;
447 const s = this.s < other.s ? this.s : other.s,
448 e = this.e > other.e ? this.e : other.e;
449 return Interval.fromDateTimes(s, e);
450 }
451
452 /**
453 * Merge an array of Intervals into a equivalent minimal set of Intervals.
454 * Combines overlapping and adjacent Intervals.
455 * @param {Array} intervals
456 * @return {Array}
457 */
458 static merge(intervals) {
459 const [found, final] = intervals
460 .sort((a, b) => a.s - b.s)
461 .reduce(
462 ([sofar, current], item) => {
463 if (!current) {
464 return [sofar, item];
465 } else if (current.overlaps(item) || current.abutsStart(item)) {
466 return [sofar, current.union(item)];
467 } else {
468 return [sofar.concat([current]), item];
469 }
470 },
471 [[], null]
472 );
473 if (final) {
474 found.push(final);
475 }
476 return found;
477 }
478
479 /**
480 * Return an array of Intervals representing the spans of time that only appear in one of the specified Intervals.
481 * @param {Array} intervals
482 * @return {Array}
483 */
484 static xor(intervals) {
485 let start = null,
486 currentCount = 0;
487 const results = [],
488 ends = intervals.map((i) => [
489 { time: i.s, type: "s" },
490 { time: i.e, type: "e" },
491 ]),
492 flattened = Array.prototype.concat(...ends),
493 arr = flattened.sort((a, b) => a.time - b.time);
494
495 for (const i of arr) {
496 currentCount += i.type === "s" ? 1 : -1;
497
498 if (currentCount === 1) {
499 start = i.time;
500 } else {
501 if (start && +start !== +i.time) {
502 results.push(Interval.fromDateTimes(start, i.time));
503 }
504
505 start = null;
506 }
507 }
508
509 return Interval.merge(results);
510 }
511
512 /**
513 * Return an Interval representing the span of time in this Interval that doesn't overlap with any of the specified Intervals.
514 * @param {...Interval} intervals
515 * @return {Array}
516 */
517 difference(...intervals) {
518 return Interval.xor([this].concat(intervals))
519 .map((i) => this.intersection(i))
520 .filter((i) => i && !i.isEmpty());
521 }
522
523 /**
524 * Returns a string representation of this Interval appropriate for debugging.
525 * @return {string}
526 */
527 toString() {
528 if (!this.isValid) return INVALID;
529 return `[${this.s.toISO()}${this.e.toISO()})`;
530 }
531
532 /**
533 * Returns an ISO 8601-compliant string representation of this Interval.
534 * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
535 * @param {Object} opts - The same options as {@link DateTime#toISO}
536 * @return {string}
537 */
538 toISO(opts) {
539 if (!this.isValid) return INVALID;
540 return `${this.s.toISO(opts)}/${this.e.toISO(opts)}`;
541 }
542
543 /**
544 * Returns an ISO 8601-compliant string representation of date of this Interval.
545 * The time components are ignored.
546 * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
547 * @return {string}
548 */
549 toISODate() {
550 if (!this.isValid) return INVALID;
551 return `${this.s.toISODate()}/${this.e.toISODate()}`;
552 }
553
554 /**
555 * Returns an ISO 8601-compliant string representation of time of this Interval.
556 * The date components are ignored.
557 * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
558 * @param {Object} opts - The same options as {@link DateTime.toISO}
559 * @return {string}
560 */
561 toISOTime(opts) {
562 if (!this.isValid) return INVALID;
563 return `${this.s.toISOTime(opts)}/${this.e.toISOTime(opts)}`;
564 }
565
566 /**
567 * Returns a string representation of this Interval formatted according to the specified format string.
568 * @param {string} dateFormat - the format string. This string formats the start and end time. See {@link DateTime.toFormat} for details.
569 * @param {Object} opts - options
570 * @param {string} [opts.separator = ' – '] - a separator to place between the start and end representations
571 * @return {string}
572 */
573 toFormat(dateFormat, { separator = " – " } = {}) {
574 if (!this.isValid) return INVALID;
575 return `${this.s.toFormat(dateFormat)}${separator}${this.e.toFormat(dateFormat)}`;
576 }
577
578 /**
579 * Return a Duration representing the time spanned by this interval.
580 * @param {string|string[]} [unit=['milliseconds']] - the unit or units (such as 'hours' or 'days') to include in the duration.
581 * @param {Object} opts - options that affect the creation of the Duration
582 * @param {string} [opts.conversionAccuracy='casual'] - the conversion system to use
583 * @example Interval.fromDateTimes(dt1, dt2).toDuration().toObject() //=> { milliseconds: 88489257 }
584 * @example Interval.fromDateTimes(dt1, dt2).toDuration('days').toObject() //=> { days: 1.0241812152777778 }
585 * @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes']).toObject() //=> { hours: 24, minutes: 34.82095 }
586 * @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes', 'seconds']).toObject() //=> { hours: 24, minutes: 34, seconds: 49.257 }
587 * @example Interval.fromDateTimes(dt1, dt2).toDuration('seconds').toObject() //=> { seconds: 88489.257 }
588 * @return {Duration}
589 */
590 toDuration(unit, opts) {
591 if (!this.isValid) {
592 return Duration.invalid(this.invalidReason);
593 }
594 return this.e.diff(this.s, unit, opts);
595 }
596
597 /**
598 * Run mapFn on the interval start and end, returning a new Interval from the resulting DateTimes
599 * @param {function} mapFn
600 * @return {Interval}
601 * @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.toUTC())
602 * @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.plus({ hours: 2 }))
603 */
604 mapEndpoints(mapFn) {
605 return Interval.fromDateTimes(mapFn(this.s), mapFn(this.e));
606 }
607}