chronoshift
Version:
A tiny library for shifting time with timezones
330 lines (329 loc) • 11.6 kB
JavaScript
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());
}
}