Home Reference Source

packages/extra/src/Interval.js

/*
 * @copyright (c) 2016, Philipp Thürwächter & Pattrick Hüper
 * @copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
 * @license BSD-3-Clause (see LICENSE in the root directory of this source tree)
 */

import { DateTimeException, DateTimeParseException, Duration, IllegalArgumentException, Instant, ZonedDateTime } from '@js-joda/core';

// TODO: hm... is this a good idea?? copied from joda currently, could we add a js-joda-utils module??
import { requireNonNull, requireInstance } from './assert';

/**
 * An immutable interval of time between two instants.
 * 
 * An interval represents the time on the time-line between two {@link Instant}s.
 * The class stores the start and end instants, with the start inclusive and the end exclusive.
 * The end instant is always greater than or equal to the start instant.
 * 
 * The {@link Duration} of an interval can be obtained, but is a separate concept.
 * An interval is connected to the time-line, whereas a duration is not.
 * 
 * Intervals are not comparable. To compare the length of two intervals, it is
 * generally recommended to compare their durations.
 *
 */
export class Interval {
    //-----------------------------------------------------------------------
    /**
     * function overloading for {@link Interval.of}
     * - If called without arguments, then {@link Interval.ofInstantInstant} is executed.
     * - If called with 1 arguments and first argument is an instance of ZoneId, then {@link Interval.ofInstantDuration} is executed.
     * - Otherwise {@link Interval.ofInstantDuration} is executed.
     *
     * @param {Instant} startInstant
     * @param {Instant|Duration} endInstantOrDuration
     * @return {Interval}
     */
    static of(startInstant, endInstantOrDuration) {
        if (endInstantOrDuration instanceof Duration) {
            return Interval.ofInstantDuration(startInstant, endInstantOrDuration);
        } else {
            return Interval.ofInstantInstant(startInstant, endInstantOrDuration);
        }
    }

    /**
     * Obtains an instance of `Interval` from the start and end instant.
     * 
     * The end instant must not be before the start instant.
     *
     * @param {Instant} startInclusive - the start instant, inclusive, MIN_DATE treated as unbounded, not null
     * @param {Instant} endExclusive - the end instant, exclusive, MAX_DATE treated as unbounded, not null
     * @return {Interval} the half-open interval, not null
     * @throws {DateTimeException} if the end is before the start
     * @protected
     */
    static ofInstantInstant(startInclusive, endExclusive) {
        requireNonNull(startInclusive, 'startInclusive');
        requireNonNull(endExclusive, 'endExclusive');
        requireInstance(startInclusive, Instant, 'startInclusive');
        requireInstance(endExclusive, Instant, 'endExclusive');
        if (endExclusive.isBefore(startInclusive)) {
            throw new DateTimeException('End instant must on or after start instant');
        }
        return new Interval(startInclusive, endExclusive);
    }

    /**
     * Obtains an instance of `Interval` from the start and a duration.
     * 
     * The end instant is calculated as the start plus the duration.
     * The duration must not be negative.
     *
     * @param {Instant} startInclusive - the start instant, inclusive, not null
     * @param {Duration} duration - the duration from the start to the end, not null
     * @return {Interval} the interval, not null
     * @throws {DateTimeException} if the end is before the start,
     *  or if the duration addition cannot be made
     * @throws {ArithmeticException} if numeric overflow occurs when adding the duration
     * @protected
     */
    static ofInstantDuration(startInclusive, duration) {
        requireNonNull(startInclusive, 'startInclusive');
        requireNonNull(duration, 'duration');
        requireInstance(startInclusive, Instant, 'startInclusive');
        requireInstance(duration, Duration, 'duration');
        if (duration.isNegative()) {
            throw new DateTimeException('Duration must not be zero or negative');
        }
        return new Interval(startInclusive, startInclusive.plus(duration));
    }

    //-----------------------------------------------------------------------

    /**
     * Obtains an instance of `Interval` from a text string such as
     * `2007-12-03T10:15:30Z/2007-12-04T10:15:30Z`, where the end instant is exclusive.
     * 
     * The string must consist of one of the following three formats:
     * - a representations of an {@link ZonedDateTime}, followed by a forward slash,
     *  followed by a representation of a {@link ZonedDateTime}
     * - a representation of an {@link ZonedDateTime}, followed by a forward slash,
     *  followed by a representation of a {@link Duration}
     * - a representation of a {@link Duration}, followed by a forward slash,
     *  followed by a representation of an {@link ZonedDateTime}
     *
     * NOTE: in contrast to the threeten-extra base we are not using `OffsetDateTime` but `ZonedDateTime` to parse
     * the string, this does not change the format but adds the possibility to optionally specify a zone
     *
     * @param {string} text - the text to parse, not null
     * @return {Interval} the parsed interval, not null
     * @throws {DateTimeParseException} if the text cannot be parsed
     */
    static parse(text) {
        requireNonNull(text, 'text');
        if (!(typeof text === 'string')) {
            throw new IllegalArgumentException(`text must be a string, but is ${text.constructor.name}`);
        }
        for (let i = 0; i < text.length; i += 1) {
            if (text.charAt(i) === '/') {
                const firstChar = text.charAt(0);
                if (firstChar === 'P' || firstChar === 'p') {
                    // duration followed by instant
                    const duration = Duration.parse(text.substring(0, i));
                    const end = ZonedDateTime.parse(text.substring(i + 1, text.length)).toInstant();
                    return Interval.of(end.minus(duration), end);
                } else {
                    // instant followed by instant or duration
                    const start = ZonedDateTime.parse(text.substring(0, i)).toInstant();
                    if (i + 1 < text.length) {
                        const c = text.charAt(i + 1);
                        if (c === 'P' || c === 'p') {
                            const duration = Duration.parse(text.substring(i + 1, text.length));
                            return Interval.of(start, start.plus(duration));
                        }
                    }
                    const end = ZonedDateTime.parse(text.substring(i + 1, text.length)).toInstant();
                    return Interval.of(start, end);
                }
            }
        }
        throw new DateTimeParseException('Interval cannot be parsed, no forward slash found', text, 0);
    }
    //-----------------------------------------------------------------------
    /**
     * Constructor.
     *
     * @param {Instant} startInclusive - the start instant, inclusive, validated not null
     * @param {Instant} endExclusive - the end instant, exclusive, validated not null
     * @private
     */
    constructor(startInclusive, endExclusive) {
        this._start = startInclusive;
        this._end = endExclusive;
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the start of this time interval, inclusive.
     * 
     * This will return {@link Instant#MIN} if the range is unbounded at the start.
     * In this case, the range includes all dates into the far-past.
     *
     * @return {Instant} the start of the time interval
     */
    start() {
        return this._start;
    }

    /**
     * Gets the end of this time interval, exclusive.
     * 
     * This will return {@link Instant#MAX} if the range is unbounded at the end.
     * In this case, the range includes all dates into the far-future.
     *
     * @return {Instant} the end of the time interval, exclusive
     */
    end() {
        return this._end;
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if the range is empty.
     * 
     * An empty range occurs when the start date equals the inclusive end date.
     *
     * @return {boolean} true if the range is empty
     */
    isEmpty() {
        return this._start.equals(this._end);
    }

    /**
     * Checks if the start of the interval is unbounded.
     *
     * @return {boolean} true if start is unbounded
     */
    isUnboundedStart() {
        return this._start.equals(Instant.MIN);
    }

    /**
     * Checks if the end of the interval is unbounded.
     *
     * @return {boolean} true if end is unbounded
     */
    isUnboundedEnd() {
        return this._end.equals(Instant.MAX);
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a copy of this range with the specified start instant.
     *
     * @param {Instant} start - the start instant for the new interval, not null
     * @return {Interval} an interval with the end from this interval and the specified start
     * @throws {DateTimeException} if the resulting interval has end before start
     */
    withStart(start) {
        return Interval.of(start, this._end);
    }

    /**
     * Returns a copy of this range with the specified end instant.
     *
     * @param {Instant} end - the end instant for the new interval, not null
     * @return {Interval} an interval with the start from this interval and the specified end
     * @throws {DateTimeException} if the resulting interval has end before start
     */
    withEnd(end) {
        return Interval.of(this._start, end);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this interval contains the specified instant.
     * 
     * This checks if the specified instant is within the bounds of this interval.
     * If this range has an unbounded start then `contains(Instant.MIN)` returns true.
     * If this range has an unbounded end then `contains(Instant.MAX)` returns true.
     * If this range is empty then this method always returns false.
     *
     * @param {Instant} instant - the instant, not null
     * @return {boolean} true if this interval contains the instant
     */
    contains(instant) {
        requireNonNull(instant, 'instant');
        requireInstance(instant, Instant, 'instant');
        return this._start.compareTo(instant) <= 0 && (instant.compareTo(this._end) < 0 || this.isUnboundedEnd());
    }

    /**
     * Checks if this interval encloses the specified interval.
     * 
     * This checks if the bounds of the specified interval are within the bounds of this interval.
     * An empty interval encloses itself.
     *
     * @param {Interval} other - the other interval, not null
     * @return {boolean} true if this interval contains the other interval
     */
    encloses(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        return this._start.compareTo(other.start()) <= 0 && other.end().compareTo(this._end) <= 0;
    }

    /**
     * Checks if this interval abuts the specified interval.
     * 
     * The result is true if the end of this interval is the start of the other, or vice versa.
     * An empty interval does not abut itself.
     *
     * @param {Interval} other - the other interval, not null
     * @return {boolean} true if this interval abuts the other interval
     */
    abuts(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        return !this._end.equals(other.start()) !== !this._start.equals(other.end());
    }

    /**
     * Checks if this interval is connected to the specified interval.
     * 
     * The result is true if the two intervals have an enclosed interval in common, even if that interval is empty.
     * An empty interval is connected to itself.
     * 
     * This is equivalent to `(overlaps(other) || abuts(other))`.
     *
     * @param {Interval} other - the other interval, not null
     * @return {boolean} true if this interval is connected to the other interval
     */
    isConnected(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        return this.equals(other) || (this._start.compareTo(other.end()) <= 0 && other.start().compareTo(this._end) <= 0);
    }

    /**
     * Checks if this interval overlaps the specified interval.
     * 
     * The result is true if the the two intervals share some part of the time-line.
     * An empty interval overlaps itself.
     * 
     * This is equivalent to `(isConnected(other) && !abuts(other))`.
     *
     * @param {Interval} other - the time interval to compare to, null means a zero length interval now
     * @return {boolean} true if the time intervals overlap
     */
    overlaps(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        return other.equals(this) || (this._start.compareTo(other.end()) < 0 && other.start().compareTo(this._end) < 0);
    }

    //-----------------------------------------------------------------------
    /**
     * Calculates the interval that is the intersection of this interval and the specified interval.
     * 
     * This finds the intersection of two intervals.
     * This throws an exception if the two intervals are not {@linkplain #isConnected(Interval) connected}.
     *
     * @param {Interval} other - the other interval to check for, not null
     * @return {Interval} the interval that is the intersection of the two intervals
     * @throws {DateTimeException} if the intervals do not connect
     */
    intersection(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        if (this.isConnected(other) === false) {
            throw new DateTimeException(`Intervals do not connect: ${this} and ${other}`);
        }
        const cmpStart = this._start.compareTo(other.start());
        const cmpEnd = this._end.compareTo(other.end());
        if (cmpStart >= 0 && cmpEnd <= 0) {
            return this;
        } else if (cmpStart <= 0 && cmpEnd >= 0) {
            return other;
        } else {
            const newStart = (cmpStart >= 0 ? this._start : other.start());
            const newEnd = (cmpEnd <= 0 ? this._end : other.end());
            return Interval.of(newStart, newEnd);
        }
    }

    /**
     * Calculates the interval that is the union of this interval and the specified interval.
     * 
     * This finds the union of two intervals.
     * This throws an exception if the two intervals are not {@linkplain #isConnected(Interval) connected}.
     *
     * @param {Interval} other - the other interval to check for, not null
     * @return {Interval} the interval that is the union of the two intervals
     * @throws {DateTimeException} if the intervals do not connect
     */
    union(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        if (this.isConnected(other) === false) {
            throw new DateTimeException(`Intervals do not connect: ${this} and ${other}`);
        }
        const cmpStart = this._start.compareTo(other.start());
        const cmpEnd = this._end.compareTo(other.end());
        if (cmpStart >= 0 && cmpEnd <= 0) {
            return other;
        } else if (cmpStart <= 0 && cmpEnd >= 0) {
            return this;
        } else {
            const newStart = (cmpStart >= 0 ? other.start() : this._start);
            const newEnd = (cmpEnd <= 0 ? other.end() : this._end);
            return Interval.of(newStart, newEnd);
        }
    }

    /**
     * Calculates the smallest interval that encloses this interval and the specified interval.
     * 
     * The result of this method will {@linkplain #encloses(Interval) enclose}
     * this interval and the specified interval.
     *
     * @param {Interval} other - the other interval to check for, not null
     * @return {Interval} the interval that spans the two intervals
     */
    span(other) {
        requireNonNull(other, 'other');
        requireInstance(other, Interval, 'other');
        const cmpStart = this._start.compareTo(other.start());
        const cmpEnd = this._end.compareTo(other.end());
        const newStart = (cmpStart >= 0 ? other.start() : this._start);
        const newEnd = (cmpEnd <= 0 ? other.end() : this._end);
        return Interval.of(newStart, newEnd);
    }

    //-------------------------------------------------------------------------
    /**
     * Function overloading for {@link Interval#isAfter}
     * - If called with an Instant, then {@link Interval#isAfterInstant} is executed.
     * - Otherwise {@link Interval#isAfterInterval} is executed.
     *
     * @param {Instant|Interval} instantOrInterval
     * @return {boolean}
     */
    isAfter(instantOrInterval) {
        if (instantOrInterval instanceof Instant) {
            return this.isAfterInstant(instantOrInterval);
        } else {
            return this.isAfterInterval(instantOrInterval);
        }
    }

    /**
     * Function overloading for {@link Interval#isBefore}
     * - If called with an Instant, then {@link Interval#isBeforeInstant} is executed.
     * - Otherwise {@link Interval#isBeforeInterval} is executed.
     *
     * @param {Instant|Interval} instantOrInterval
     * @return {boolean}
     */
    isBefore(instantOrInterval) {
        if (instantOrInterval instanceof Instant) {
            return this.isBeforeInstant(instantOrInterval);
        } else {
            return this.isBeforeInterval(instantOrInterval);
        }
    }

    /**
     * Checks if this interval is after the specified instant.
     * 
     * The result is true if the this instant starts after the specified instant.
     * An empty interval behaves as though it is an instant for comparison purposes.
     *
     * @param {Instant} instant - the other instant to compare to, not null
     * @return {boolean} true if the start of this interval is after the specified instant
     */
    isAfterInstant(instant) {
        return this._start.compareTo(instant) > 0;
    }

    /**
     * Checks if this interval is before the specified instant.
     * 
     * The result is true if the this instant ends before the specified instant.
     * Since intervals do not include their end points, this will return true if the
     * instant equals the end of the interval.
     * An empty interval behaves as though it is an instant for comparison purposes.
     *
     * @param {Instant} instant - the other instant to compare to, not null
     * @return {boolean} true if the start of this interval is before the specified instant
     */
    isBeforeInstant(instant) {
        return this._end.compareTo(instant) <= 0 && this._start.compareTo(instant) < 0;
    }

    //-------------------------------------------------------------------------
    /**
     * Checks if this interval is after the specified interval.
     * 
     * The result is true if the this instant starts after the end of the specified interval.
     * Since intervals do not include their end points, this will return true if the
     * instant equals the end of the interval.
     * An empty interval behaves as though it is an instant for comparison purposes.
     *
     * @param {Interval} interval - the other interval to compare to, not null
     * @return {boolean} true if this instant is after the specified instant
     */
    isAfterInterval(interval) {
        return this._start.compareTo(interval.end()) >= 0 && !interval.equals(this);
    }

    /**
     * Checks if this interval is before the specified interval.
     * 
     * The result is true if the this instant ends before the start of the specified interval.
     * Since intervals do not include their end points, this will return true if the
     * two intervals abut.
     * An empty interval behaves as though it is an instant for comparison purposes.
     *
     * @param {Interval} interval - the other interval to compare to, not null
     * @return {boolean} true if this instant is before the specified instant
     */
    isBeforeInterval(interval) {
        return this._end.compareTo(interval.start()) <= 0 && !interval.equals(this);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains the duration of this interval.
     * 
     * An `Interval` is associated with two specific instants on the time-line.
     * A `Duration` is simply an amount of time, separate from the time-line.
     *
     * @return {Duration} the duration of the time interval
     * @throws {ArithmeticException} if the calculation exceeds the capacity of `Duration`
     */
    toDuration() {
        return Duration.between(this._start, this._end);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this interval is equal to another interval.
     * 
     * Compares this `Interval` with another ensuring that the two instants are the same.
     * Only objects of type `Interval` are compared, other types return false.
     *
     * @param {*} obj - the object to check, null returns false
     * @return {boolean} true if this is equal to the other interval
     */
    equals(obj) {
        if (this === obj) {
            return true;
        }
        if (obj instanceof Interval) {
            return this._start.equals(obj.start()) && this._end.equals(obj.end());
        }
        return false;
    }

    /**
     * A hash code for this interval.
     *
     * @return {number} a suitable hash code
     */
    hashCode() {
        // eslint-disable-next-line no-bitwise
        return this._start.hashCode() ^ this._end.hashCode();
    }

    //-----------------------------------------------------------------------
    /**
     * Outputs this interval as a `String`, such as `2007-12-03T10:15:30/2007-12-04T10:15:30`.
     * 
     * The output will be the ISO-8601 format formed by combining the
     * `toString()` methods of the two instants, separated by a forward slash.
     *
     * @return {string} a string representation of this instant, not null
     */
    toString() {
        return `${this._start.toString()}/${this._end.toString()}`;
    }
}

export function _init() {
    Interval.ALL = Interval.of(Instant.MIN, Instant.MAX);
}