| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607 |
- import DateTime, { friendlyDateTime } from "./datetime.js";
- import Duration from "./duration.js";
- import Settings from "./settings.js";
- import { InvalidArgumentError, InvalidIntervalError } from "./errors.js";
- import Invalid from "./impl/invalid.js";
- const INVALID = "Invalid Interval";
- // checks if the start is equal to or before the end
- function validateStartEnd(start, end) {
- if (!start || !start.isValid) {
- return Interval.invalid("missing or invalid start");
- } else if (!end || !end.isValid) {
- return Interval.invalid("missing or invalid end");
- } else if (end < start) {
- return Interval.invalid(
- "end before start",
- `The end of an interval must be after its start, but you had start=${start.toISO()} and end=${end.toISO()}`
- );
- } else {
- return null;
- }
- }
- /**
- * 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.
- *
- * Here is a brief overview of the most commonly used methods and getters in Interval:
- *
- * * **Creation** To create an Interval, use {@link Interval.fromDateTimes}, {@link Interval.after}, {@link Interval.before}, or {@link Interval.fromISO}.
- * * **Accessors** Use {@link Interval#start} and {@link Interval#end} to get the start and end.
- * * **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}.
- * * **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}.
- * * **Comparison** To compare this Interval to another one, use {@link Interval#equals}, {@link Interval#overlaps}, {@link Interval#abutsStart}, {@link Interval#abutsEnd}, {@link Interval#engulfs}
- * * **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}.
- */
- export default class Interval {
- /**
- * @private
- */
- constructor(config) {
- /**
- * @access private
- */
- this.s = config.start;
- /**
- * @access private
- */
- this.e = config.end;
- /**
- * @access private
- */
- this.invalid = config.invalid || null;
- /**
- * @access private
- */
- this.isLuxonInterval = true;
- }
- /**
- * Create an invalid Interval.
- * @param {string} reason - simple string of why this Interval is invalid. Should not contain parameters or anything else data-dependent
- * @param {string} [explanation=null] - longer explanation, may include parameters and other useful debugging information
- * @return {Interval}
- */
- static invalid(reason, explanation = null) {
- if (!reason) {
- throw new InvalidArgumentError("need to specify a reason the Interval is invalid");
- }
- const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
- if (Settings.throwOnInvalid) {
- throw new InvalidIntervalError(invalid);
- } else {
- return new Interval({ invalid });
- }
- }
- /**
- * Create an Interval from a start DateTime and an end DateTime. Inclusive of the start but not the end.
- * @param {DateTime|Date|Object} start
- * @param {DateTime|Date|Object} end
- * @return {Interval}
- */
- static fromDateTimes(start, end) {
- const builtStart = friendlyDateTime(start),
- builtEnd = friendlyDateTime(end);
- const validateError = validateStartEnd(builtStart, builtEnd);
- if (validateError == null) {
- return new Interval({
- start: builtStart,
- end: builtEnd,
- });
- } else {
- return validateError;
- }
- }
- /**
- * Create an Interval from a start DateTime and a Duration to extend to.
- * @param {DateTime|Date|Object} start
- * @param {Duration|Object|number} duration - the length of the Interval.
- * @return {Interval}
- */
- static after(start, duration) {
- const dur = Duration.fromDurationLike(duration),
- dt = friendlyDateTime(start);
- return Interval.fromDateTimes(dt, dt.plus(dur));
- }
- /**
- * Create an Interval from an end DateTime and a Duration to extend backwards to.
- * @param {DateTime|Date|Object} end
- * @param {Duration|Object|number} duration - the length of the Interval.
- * @return {Interval}
- */
- static before(end, duration) {
- const dur = Duration.fromDurationLike(duration),
- dt = friendlyDateTime(end);
- return Interval.fromDateTimes(dt.minus(dur), dt);
- }
- /**
- * Create an Interval from an ISO 8601 string.
- * Accepts `<start>/<end>`, `<start>/<duration>`, and `<duration>/<end>` formats.
- * @param {string} text - the ISO string to parse
- * @param {Object} [opts] - options to pass {@link DateTime#fromISO} and optionally {@link Duration#fromISO}
- * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
- * @return {Interval}
- */
- static fromISO(text, opts) {
- const [s, e] = (text || "").split("/", 2);
- if (s && e) {
- let start, startIsValid;
- try {
- start = DateTime.fromISO(s, opts);
- startIsValid = start.isValid;
- } catch (e) {
- startIsValid = false;
- }
- let end, endIsValid;
- try {
- end = DateTime.fromISO(e, opts);
- endIsValid = end.isValid;
- } catch (e) {
- endIsValid = false;
- }
- if (startIsValid && endIsValid) {
- return Interval.fromDateTimes(start, end);
- }
- if (startIsValid) {
- const dur = Duration.fromISO(e, opts);
- if (dur.isValid) {
- return Interval.after(start, dur);
- }
- } else if (endIsValid) {
- const dur = Duration.fromISO(s, opts);
- if (dur.isValid) {
- return Interval.before(end, dur);
- }
- }
- }
- return Interval.invalid("unparsable", `the input "${text}" can't be parsed as ISO 8601`);
- }
- /**
- * Check if an object is an Interval. Works across context boundaries
- * @param {object} o
- * @return {boolean}
- */
- static isInterval(o) {
- return (o && o.isLuxonInterval) || false;
- }
- /**
- * Returns the start of the Interval
- * @type {DateTime}
- */
- get start() {
- return this.isValid ? this.s : null;
- }
- /**
- * Returns the end of the Interval
- * @type {DateTime}
- */
- get end() {
- return this.isValid ? this.e : null;
- }
- /**
- * Returns whether this Interval's end is at least its start, meaning that the Interval isn't 'backwards'.
- * @type {boolean}
- */
- get isValid() {
- return this.invalidReason === null;
- }
- /**
- * Returns an error code if this Interval is invalid, or null if the Interval is valid
- * @type {string}
- */
- get invalidReason() {
- return this.invalid ? this.invalid.reason : null;
- }
- /**
- * Returns an explanation of why this Interval became invalid, or null if the Interval is valid
- * @type {string}
- */
- get invalidExplanation() {
- return this.invalid ? this.invalid.explanation : null;
- }
- /**
- * Returns the length of the Interval in the specified unit.
- * @param {string} unit - the unit (such as 'hours' or 'days') to return the length in.
- * @return {number}
- */
- length(unit = "milliseconds") {
- return this.isValid ? this.toDuration(...[unit]).get(unit) : NaN;
- }
- /**
- * Returns the count of minutes, hours, days, months, or years included in the Interval, even in part.
- * Unlike {@link Interval#length} this counts sections of the calendar, not periods of time, e.g. specifying 'day'
- * asks 'what dates are included in this interval?', not 'how many days long is this interval?'
- * @param {string} [unit='milliseconds'] - the unit of time to count.
- * @return {number}
- */
- count(unit = "milliseconds") {
- if (!this.isValid) return NaN;
- const start = this.start.startOf(unit),
- end = this.end.startOf(unit);
- return Math.floor(end.diff(start, unit).get(unit)) + 1;
- }
- /**
- * Returns whether this Interval's start and end are both in the same unit of time
- * @param {string} unit - the unit of time to check sameness on
- * @return {boolean}
- */
- hasSame(unit) {
- return this.isValid ? this.isEmpty() || this.e.minus(1).hasSame(this.s, unit) : false;
- }
- /**
- * Return whether this Interval has the same start and end DateTimes.
- * @return {boolean}
- */
- isEmpty() {
- return this.s.valueOf() === this.e.valueOf();
- }
- /**
- * Return whether this Interval's start is after the specified DateTime.
- * @param {DateTime} dateTime
- * @return {boolean}
- */
- isAfter(dateTime) {
- if (!this.isValid) return false;
- return this.s > dateTime;
- }
- /**
- * Return whether this Interval's end is before the specified DateTime.
- * @param {DateTime} dateTime
- * @return {boolean}
- */
- isBefore(dateTime) {
- if (!this.isValid) return false;
- return this.e <= dateTime;
- }
- /**
- * Return whether this Interval contains the specified DateTime.
- * @param {DateTime} dateTime
- * @return {boolean}
- */
- contains(dateTime) {
- if (!this.isValid) return false;
- return this.s <= dateTime && this.e > dateTime;
- }
- /**
- * "Sets" the start and/or end dates. Returns a newly-constructed Interval.
- * @param {Object} values - the values to set
- * @param {DateTime} values.start - the starting DateTime
- * @param {DateTime} values.end - the ending DateTime
- * @return {Interval}
- */
- set({ start, end } = {}) {
- if (!this.isValid) return this;
- return Interval.fromDateTimes(start || this.s, end || this.e);
- }
- /**
- * Split this Interval at each of the specified DateTimes
- * @param {...DateTime} dateTimes - the unit of time to count.
- * @return {Array}
- */
- splitAt(...dateTimes) {
- if (!this.isValid) return [];
- const sorted = dateTimes
- .map(friendlyDateTime)
- .filter((d) => this.contains(d))
- .sort(),
- results = [];
- let { s } = this,
- i = 0;
- while (s < this.e) {
- const added = sorted[i] || this.e,
- next = +added > +this.e ? this.e : added;
- results.push(Interval.fromDateTimes(s, next));
- s = next;
- i += 1;
- }
- return results;
- }
- /**
- * Split this Interval into smaller Intervals, each of the specified length.
- * Left over time is grouped into a smaller interval
- * @param {Duration|Object|number} duration - The length of each resulting interval.
- * @return {Array}
- */
- splitBy(duration) {
- const dur = Duration.fromDurationLike(duration);
- if (!this.isValid || !dur.isValid || dur.as("milliseconds") === 0) {
- return [];
- }
- let { s } = this,
- idx = 1,
- next;
- const results = [];
- while (s < this.e) {
- const added = this.start.plus(dur.mapUnits((x) => x * idx));
- next = +added > +this.e ? this.e : added;
- results.push(Interval.fromDateTimes(s, next));
- s = next;
- idx += 1;
- }
- return results;
- }
- /**
- * Split this Interval into the specified number of smaller intervals.
- * @param {number} numberOfParts - The number of Intervals to divide the Interval into.
- * @return {Array}
- */
- divideEqually(numberOfParts) {
- if (!this.isValid) return [];
- return this.splitBy(this.length() / numberOfParts).slice(0, numberOfParts);
- }
- /**
- * Return whether this Interval overlaps with the specified Interval
- * @param {Interval} other
- * @return {boolean}
- */
- overlaps(other) {
- return this.e > other.s && this.s < other.e;
- }
- /**
- * Return whether this Interval's end is adjacent to the specified Interval's start.
- * @param {Interval} other
- * @return {boolean}
- */
- abutsStart(other) {
- if (!this.isValid) return false;
- return +this.e === +other.s;
- }
- /**
- * Return whether this Interval's start is adjacent to the specified Interval's end.
- * @param {Interval} other
- * @return {boolean}
- */
- abutsEnd(other) {
- if (!this.isValid) return false;
- return +other.e === +this.s;
- }
- /**
- * Return whether this Interval engulfs the start and end of the specified Interval.
- * @param {Interval} other
- * @return {boolean}
- */
- engulfs(other) {
- if (!this.isValid) return false;
- return this.s <= other.s && this.e >= other.e;
- }
- /**
- * Return whether this Interval has the same start and end as the specified Interval.
- * @param {Interval} other
- * @return {boolean}
- */
- equals(other) {
- if (!this.isValid || !other.isValid) {
- return false;
- }
- return this.s.equals(other.s) && this.e.equals(other.e);
- }
- /**
- * Return an Interval representing the intersection of this Interval and the specified Interval.
- * Specifically, the resulting Interval has the maximum start time and the minimum end time of the two Intervals.
- * Returns null if the intersection is empty, meaning, the intervals don't intersect.
- * @param {Interval} other
- * @return {Interval}
- */
- intersection(other) {
- if (!this.isValid) return this;
- const s = this.s > other.s ? this.s : other.s,
- e = this.e < other.e ? this.e : other.e;
- if (s >= e) {
- return null;
- } else {
- return Interval.fromDateTimes(s, e);
- }
- }
- /**
- * Return an Interval representing the union of this Interval and the specified Interval.
- * Specifically, the resulting Interval has the minimum start time and the maximum end time of the two Intervals.
- * @param {Interval} other
- * @return {Interval}
- */
- union(other) {
- if (!this.isValid) return this;
- const s = this.s < other.s ? this.s : other.s,
- e = this.e > other.e ? this.e : other.e;
- return Interval.fromDateTimes(s, e);
- }
- /**
- * Merge an array of Intervals into a equivalent minimal set of Intervals.
- * Combines overlapping and adjacent Intervals.
- * @param {Array} intervals
- * @return {Array}
- */
- static merge(intervals) {
- const [found, final] = intervals
- .sort((a, b) => a.s - b.s)
- .reduce(
- ([sofar, current], item) => {
- if (!current) {
- return [sofar, item];
- } else if (current.overlaps(item) || current.abutsStart(item)) {
- return [sofar, current.union(item)];
- } else {
- return [sofar.concat([current]), item];
- }
- },
- [[], null]
- );
- if (final) {
- found.push(final);
- }
- return found;
- }
- /**
- * Return an array of Intervals representing the spans of time that only appear in one of the specified Intervals.
- * @param {Array} intervals
- * @return {Array}
- */
- static xor(intervals) {
- let start = null,
- currentCount = 0;
- const results = [],
- ends = intervals.map((i) => [
- { time: i.s, type: "s" },
- { time: i.e, type: "e" },
- ]),
- flattened = Array.prototype.concat(...ends),
- arr = flattened.sort((a, b) => a.time - b.time);
- for (const i of arr) {
- currentCount += i.type === "s" ? 1 : -1;
- if (currentCount === 1) {
- start = i.time;
- } else {
- if (start && +start !== +i.time) {
- results.push(Interval.fromDateTimes(start, i.time));
- }
- start = null;
- }
- }
- return Interval.merge(results);
- }
- /**
- * Return an Interval representing the span of time in this Interval that doesn't overlap with any of the specified Intervals.
- * @param {...Interval} intervals
- * @return {Array}
- */
- difference(...intervals) {
- return Interval.xor([this].concat(intervals))
- .map((i) => this.intersection(i))
- .filter((i) => i && !i.isEmpty());
- }
- /**
- * Returns a string representation of this Interval appropriate for debugging.
- * @return {string}
- */
- toString() {
- if (!this.isValid) return INVALID;
- return `[${this.s.toISO()} – ${this.e.toISO()})`;
- }
- /**
- * Returns an ISO 8601-compliant string representation of this Interval.
- * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
- * @param {Object} opts - The same options as {@link DateTime#toISO}
- * @return {string}
- */
- toISO(opts) {
- if (!this.isValid) return INVALID;
- return `${this.s.toISO(opts)}/${this.e.toISO(opts)}`;
- }
- /**
- * Returns an ISO 8601-compliant string representation of date of this Interval.
- * The time components are ignored.
- * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
- * @return {string}
- */
- toISODate() {
- if (!this.isValid) return INVALID;
- return `${this.s.toISODate()}/${this.e.toISODate()}`;
- }
- /**
- * Returns an ISO 8601-compliant string representation of time of this Interval.
- * The date components are ignored.
- * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
- * @param {Object} opts - The same options as {@link DateTime#toISO}
- * @return {string}
- */
- toISOTime(opts) {
- if (!this.isValid) return INVALID;
- return `${this.s.toISOTime(opts)}/${this.e.toISOTime(opts)}`;
- }
- /**
- * Returns a string representation of this Interval formatted according to the specified format string.
- * @param {string} dateFormat - the format string. This string formats the start and end time. See {@link DateTime#toFormat} for details.
- * @param {Object} opts - options
- * @param {string} [opts.separator = ' – '] - a separator to place between the start and end representations
- * @return {string}
- */
- toFormat(dateFormat, { separator = " – " } = {}) {
- if (!this.isValid) return INVALID;
- return `${this.s.toFormat(dateFormat)}${separator}${this.e.toFormat(dateFormat)}`;
- }
- /**
- * Return a Duration representing the time spanned by this interval.
- * @param {string|string[]} [unit=['milliseconds']] - the unit or units (such as 'hours' or 'days') to include in the duration.
- * @param {Object} opts - options that affect the creation of the Duration
- * @param {string} [opts.conversionAccuracy='casual'] - the conversion system to use
- * @example Interval.fromDateTimes(dt1, dt2).toDuration().toObject() //=> { milliseconds: 88489257 }
- * @example Interval.fromDateTimes(dt1, dt2).toDuration('days').toObject() //=> { days: 1.0241812152777778 }
- * @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes']).toObject() //=> { hours: 24, minutes: 34.82095 }
- * @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes', 'seconds']).toObject() //=> { hours: 24, minutes: 34, seconds: 49.257 }
- * @example Interval.fromDateTimes(dt1, dt2).toDuration('seconds').toObject() //=> { seconds: 88489.257 }
- * @return {Duration}
- */
- toDuration(unit, opts) {
- if (!this.isValid) {
- return Duration.invalid(this.invalidReason);
- }
- return this.e.diff(this.s, unit, opts);
- }
- /**
- * Run mapFn on the interval start and end, returning a new Interval from the resulting DateTimes
- * @param {function} mapFn
- * @return {Interval}
- * @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.toUTC())
- * @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.plus({ hours: 2 }))
- */
- mapEndpoints(mapFn) {
- return Interval.fromDateTimes(mapFn(this.s), mapFn(this.e));
- }
- }
|