UNPKG

chronoshift

Version:

A tiny library for shifting time with timezones

442 lines (390 loc) 14.5 kB
/* * Copyright 2014-2015 Metamarkets Group Inc. * Copyright 2015-2019 Imply Data, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { second, shifters } from '../floor-shift-ceil/floor-shift-ceil'; import type { Timezone } from '../timezone/timezone'; import type { ImmutableClassInstance } from '../utils/utils'; 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']; export interface DurationValue { year?: number; month?: number; week?: number; day?: number; hour?: number; minute?: number; second?: number; // Indexable [span: string]: number | undefined; } function capitalizeFirst(str: string): string { 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)?)?$/; // P (year ) (month ) (day ) T(hour ) (minute ) (second ) function getSpansFromString(durationStr: string): DurationValue { const spans: DurationValue = {}; let matches: RegExpExecArray | null; 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: Date, end: Date, timezone: Timezone): DurationValue { start = second.floor(start, timezone); end = second.floor(end, timezone); if (end <= start) throw new Error('start must come before end'); const spans: DurationValue = {}; let iterator: Date = start; for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { const span = SPANS_WITHOUT_WEEK[i]; let spanCount = 0; // Shortcut const length = end.valueOf() - iterator.valueOf(); const canonicalLength: number = shifters[span].canonicalLength; if (length < canonicalLength / 4) continue; const numberToFit = Math.min(0, Math.floor(length / canonicalLength) - 1); let iteratorMove: Date; if (numberToFit > 0) { // try to skip by numberToFit 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: DurationValue): DurationValue { const newSpans: DurationValue = {}; 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: number, spansToCheck: string[]): Record<string, number> { const spans: Record<string, number> = {}; 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; } /** * Represents an ISO duration like P1DT3H */ export class Duration implements ImmutableClassInstance<DurationValue, string> { public readonly singleSpan?: string; public readonly spans: DurationValue; static fromJS(durationStr: string): Duration { if (typeof durationStr !== 'string') throw new TypeError('Duration JS must be a string'); return new Duration(getSpansFromString(durationStr)); } static fromCanonicalLength(length: number, skipMonths = false): Duration { 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 && // Weeks fits (Object.keys(spans).length > 1 || // We already have a more complex span spans['day']) // or... we only have days and it might be simpler to express as weeks ) { spans = { week: length / shifters['week'].canonicalLength }; } return new Duration(spans); } static fromCanonicalLengthUpToDays(length: number): Duration { if (length <= 0) throw new Error('length must be positive'); return new Duration(fitIntoSpans(length, SPANS_UP_TO_DAY)); } static fromRange(start: Date, end: Date, timezone: Timezone): Duration { return new Duration(getSpansFromStartEnd(start, end, timezone)); } /** * Constructs a Duration from a string (like 'P1DT3H') or a DurationValue */ // Type overloads constructor(spans: DurationValue | string); /** @deprecated Use Duration.fromRange instead */ constructor(start: Date, end: Date, timezone: Timezone); // Implementation constructor(spans: any, end?: Date, timezone?: 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; } public toString(short?: boolean) { const strArr: string[] = 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(''); } public add(duration: Duration): Duration { return Duration.fromCanonicalLength(this.getCanonicalLength() + duration.getCanonicalLength()); } public subtract(duration: Duration): Duration { const newCanonicalDuration = this.getCanonicalLength() - duration.getCanonicalLength(); if (newCanonicalDuration < 0) throw new Error('A duration can not be negative.'); return Duration.fromCanonicalLength(newCanonicalDuration); } public multiply(multiplier: number): Duration { 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); } public valueOf() { return this.spans; } public toJS() { return this.toString(); } public toJSON() { return this.toString(); } public equals(other: Duration | undefined): boolean { return other instanceof Duration && this.toString() === other.toString(); } public isSimple(): boolean { const { singleSpan } = this; if (!singleSpan) return false; return this.spans[singleSpan] === 1; } public isFloorable(): boolean { 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; } public makeFloorable(): Duration { 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 }); } /** * Floors the date according to this duration. * @param date The date to floor * @param timezone The timezone within which to floor */ public floor(date: Date, timezone: Timezone): Date { 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; } /** * Ceilings the date according to this duration * @param date The date to ceiling * @param timezone The timezone within which to operate */ public ceil(date: Date, timezone: Timezone): Date { const floored = this.floor(date, timezone); if (floored.valueOf() === date.valueOf()) return date; // Just like ceil(3) is 3 and not 4 return this.shift(floored, timezone, 1); } /** * Moves the given date by 'step' times of the duration * Negative step value will move back in time. * @param date The date to move * @param timezone The timezone within which to make the move * @param step The number of times to step by the duration */ public shift(date: Date, timezone: Timezone, step = 1): Date { 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; } /** * Rounds the date according to this duration (goes to the closest of floor(date) and ceil(date) * @param date The date to round * @param timezone The timezone within which to operate */ public round(date: Date, timezone: Timezone): Date { 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; } /** * Gives the [start, end] of the duration sized bucket in which this date belongs * @param date The date to bucket * @param timezone The timezone within which to operate */ public range(date: Date, timezone: Timezone): [Date, Date] { const start = this.floor(date, timezone); return [start, this.shift(start, timezone, 1)]; } /** * Materializes all the values of this duration form start to end * @param start The date to start on * @param end The date to start on * @param timezone The timezone within which to materialize * @param step The number of times to step by the duration */ public materialize(start: Date, end: Date, timezone: Timezone, step = 1): Date[] { const values: Date[] = []; let iter = this.makeFloorable().ceil(start, timezone); while (iter <= end) { values.push(iter); iter = this.shift(iter, timezone, step); } return values; } /** * Checks to see if date is aligned to this duration within the timezone (floors to itself) * @param date The date to check * @param timezone The timezone within which to make the check */ public isAligned(date: Date, timezone: Timezone): boolean { return this.floor(date, timezone).valueOf() === date.valueOf(); } /** * Check to see if this duration can be divided by the given duration * @param smaller The smaller duration to divide by */ public dividesBy(smaller: Duration): boolean { const myCanonicalLength = this.getCanonicalLength(); const smallerCanonicalLength = smaller.getCanonicalLength(); return ( myCanonicalLength % smallerCanonicalLength === 0 && this.isFloorable() && smaller.isFloorable() ); } public getCanonicalLength(): number { 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; } public getDescription(capitalize?: boolean): string { const spans = this.spans; const description: string[] = []; 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(', '); } public getSingleSpan(): string | undefined { return this.singleSpan; } public getSingleSpanValue(): number | undefined { if (!this.singleSpan) return; return this.spans[this.singleSpan]; } public limitToDays(): Duration { return Duration.fromCanonicalLengthUpToDays(this.getCanonicalLength()); } }