UNPKG

chronoshift

Version:

A tiny library for shifting time with timezones

330 lines (329 loc) 11.6 kB
import { second, shifters } from '../floor-shift-ceil/floor-shift-ceil'; const SPANS_WITH_WEEK = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; const SPANS_WITHOUT_WEEK = ['year', 'month', 'day', 'hour', 'minute', 'second']; const SPANS_WITHOUT_WEEK_OR_MONTH = ['year', 'day', 'hour', 'minute', 'second']; const SPANS_UP_TO_DAY = ['day', 'hour', 'minute', 'second']; function capitalizeFirst(str) { if (!str.length) return str; return str[0].toUpperCase() + str.slice(1); } const periodWeekRegExp = /^P(\d+)W$/; const periodRegExp = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:((\d+\.)?\d+)S)?)?$/; function getSpansFromString(durationStr) { const spans = {}; let matches; if ((matches = periodWeekRegExp.exec(durationStr))) { spans.week = Number(matches[1]); if (!spans.week) throw new Error('Duration can not have empty weeks'); } else if ((matches = periodRegExp.exec(durationStr))) { const nums = matches.map(Number); for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { const span = SPANS_WITHOUT_WEEK[i]; const value = nums[i + 1]; if (value) spans[span] = value; } } else { throw new Error("Can not parse duration '" + durationStr + "'"); } return spans; } function getSpansFromStartEnd(start, end, timezone) { start = second.floor(start, timezone); end = second.floor(end, timezone); if (end <= start) throw new Error('start must come before end'); const spans = {}; let iterator = start; for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { const span = SPANS_WITHOUT_WEEK[i]; let spanCount = 0; const length = end.valueOf() - iterator.valueOf(); const canonicalLength = shifters[span].canonicalLength; if (length < canonicalLength / 4) continue; const numberToFit = Math.min(0, Math.floor(length / canonicalLength) - 1); let iteratorMove; if (numberToFit > 0) { iteratorMove = shifters[span].shift(iterator, timezone, numberToFit); if (iteratorMove <= end) { spanCount += numberToFit; iterator = iteratorMove; } } while (true) { iteratorMove = shifters[span].shift(iterator, timezone, 1); if (iteratorMove <= end) { iterator = iteratorMove; spanCount++; } else { break; } } if (spanCount) { spans[span] = spanCount; } } return spans; } function removeZeros(spans) { const newSpans = {}; for (let i = 0; i < SPANS_WITH_WEEK.length; i++) { const span = SPANS_WITH_WEEK[i]; if (Number(spans[span]) > 0) { newSpans[span] = spans[span]; } } return newSpans; } function fitIntoSpans(length, spansToCheck) { const spans = {}; let lengthLeft = length; for (let i = 0; i < spansToCheck.length; i++) { const span = spansToCheck[i]; const spanLength = shifters[span].canonicalLength; const count = Math.floor(lengthLeft / spanLength); if (count) { lengthLeft -= spanLength * count; spans[span] = count; } } return spans; } export class Duration { static fromJS(durationStr) { if (typeof durationStr !== 'string') throw new TypeError('Duration JS must be a string'); return new Duration(getSpansFromString(durationStr)); } static fromCanonicalLength(length, skipMonths = false) { if (length <= 0) throw new Error('length must be positive'); let spans = fitIntoSpans(length, skipMonths ? SPANS_WITHOUT_WEEK_OR_MONTH : SPANS_WITHOUT_WEEK); if (length % shifters['week'].canonicalLength === 0 && (Object.keys(spans).length > 1 || spans['day'])) { spans = { week: length / shifters['week'].canonicalLength }; } return new Duration(spans); } static fromCanonicalLengthUpToDays(length) { if (length <= 0) throw new Error('length must be positive'); return new Duration(fitIntoSpans(length, SPANS_UP_TO_DAY)); } static fromRange(start, end, timezone) { return new Duration(getSpansFromStartEnd(start, end, timezone)); } constructor(spans, end, timezone) { if (spans && end && timezone) { spans = getSpansFromStartEnd(spans, end, timezone); } else if (typeof spans === 'object') { spans = removeZeros(spans); } else if (typeof spans === 'string') { spans = getSpansFromString(spans); } else { throw new Error('new Duration called with bad argument'); } const usedSpans = Object.keys(spans); if (!usedSpans.length) throw new Error('Duration can not be empty'); if (usedSpans.length === 1) { this.singleSpan = usedSpans[0]; } else if (spans.week) { throw new Error("Can not mix 'week' and other spans"); } this.spans = spans; } toString(short) { const strArr = short ? [] : ['P']; const spans = this.spans; if (spans.week) { strArr.push(String(spans.week), 'W'); } else { let needsT = !(short && this.singleSpan); for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { const span = SPANS_WITHOUT_WEEK[i]; const value = spans[span]; if (!value) continue; if (needsT && i >= 3) { strArr.push('T'); needsT = false; } strArr.push(String(value), span[0].toUpperCase()); } } return strArr.join(''); } add(duration) { return Duration.fromCanonicalLength(this.getCanonicalLength() + duration.getCanonicalLength()); } subtract(duration) { const newCanonicalDuration = this.getCanonicalLength() - duration.getCanonicalLength(); if (newCanonicalDuration < 0) throw new Error('A duration can not be negative.'); return Duration.fromCanonicalLength(newCanonicalDuration); } multiply(multiplier) { if (multiplier <= 0) throw new Error('Multiplier must be positive non-zero'); if (multiplier === 1) return this; const newCanonicalDuration = this.getCanonicalLength() * multiplier; return Duration.fromCanonicalLength(newCanonicalDuration); } valueOf() { return this.spans; } toJS() { return this.toString(); } toJSON() { return this.toString(); } equals(other) { return other instanceof Duration && this.toString() === other.toString(); } isSimple() { const { singleSpan } = this; if (!singleSpan) return false; return this.spans[singleSpan] === 1; } isFloorable() { const { singleSpan } = this; if (!singleSpan) return false; const span = Number(this.spans[singleSpan]); if (span === 1) return true; const { siblings } = shifters[singleSpan]; if (!siblings) return false; return siblings % span === 0; } makeFloorable() { if (this.isFloorable()) return this; const { singleSpan, spans } = this; if (singleSpan) return new Duration({ [singleSpan]: 1 }); for (const span of SPANS_WITH_WEEK) { if (spans[span]) return new Duration({ [span]: 1 }); } return new Duration({ second: 1 }); } floor(date, timezone) { const { singleSpan } = this; if (!singleSpan) throw new Error('Can not operate on a complex duration'); const span = this.spans[singleSpan]; const mover = shifters[singleSpan]; let dt = mover.floor(date, timezone); if (span !== 1) { if (!mover.siblings) { throw new Error(`Can not operate on a ${singleSpan} duration that is not 1`); } if (mover.siblings % span !== 0) { throw new Error(`Can not operate on a ${singleSpan} duration that does not divide into ${mover.siblings}`); } dt = mover.round(dt, span, timezone); } return dt; } ceil(date, timezone) { const floored = this.floor(date, timezone); if (floored.valueOf() === date.valueOf()) return date; return this.shift(floored, timezone, 1); } shift(date, timezone, step = 1) { const spans = this.spans; for (const span of SPANS_WITH_WEEK) { const value = spans[span]; if (value) date = shifters[span].shift(date, timezone, step * value); } return date; } round(date, timezone) { const floorDate = this.floor(date, timezone); const ceilDate = this.ceil(date, timezone); const distanceToFloor = Math.abs(date.valueOf() - floorDate.valueOf()); const distanceToCeil = Math.abs(date.valueOf() - ceilDate.valueOf()); return distanceToFloor <= distanceToCeil ? floorDate : ceilDate; } range(date, timezone) { const start = this.floor(date, timezone); return [start, this.shift(start, timezone, 1)]; } materialize(start, end, timezone, step = 1) { const values = []; let iter = this.makeFloorable().ceil(start, timezone); while (iter <= end) { values.push(iter); iter = this.shift(iter, timezone, step); } return values; } isAligned(date, timezone) { return this.floor(date, timezone).valueOf() === date.valueOf(); } dividesBy(smaller) { const myCanonicalLength = this.getCanonicalLength(); const smallerCanonicalLength = smaller.getCanonicalLength(); return (myCanonicalLength % smallerCanonicalLength === 0 && this.isFloorable() && smaller.isFloorable()); } getCanonicalLength() { const spans = this.spans; let length = 0; for (const span of SPANS_WITH_WEEK) { const value = spans[span]; if (value) length += value * shifters[span].canonicalLength; } return length; } getDescription(capitalize) { const spans = this.spans; const description = []; for (const span of SPANS_WITH_WEEK) { const value = spans[span]; const spanTitle = capitalize ? capitalizeFirst(span) : span; if (value) { if (value === 1) { description.push(spanTitle); } else { description.push(String(value) + ' ' + spanTitle + 's'); } } } return description.join(', '); } getSingleSpan() { return this.singleSpan; } getSingleSpanValue() { if (!this.singleSpan) return; return this.spans[this.singleSpan]; } limitToDays() { return Duration.fromCanonicalLengthUpToDays(this.getCanonicalLength()); } }