interval.js 19 KB

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