328 lines
9.5 KiB
JavaScript
328 lines
9.5 KiB
JavaScript
import { pick, omit, isEqual, isEmpty } from 'lodash';
|
|
import dateformat from '~/lib/dateformat';
|
|
import { DATETIME_RANGE_TYPES } from './constants';
|
|
import { secondsToMilliseconds } from './datetime_utility';
|
|
|
|
const MINIMUM_DATE = new Date(0);
|
|
|
|
const DEFAULT_DIRECTION = 'before';
|
|
|
|
const durationToMillis = (duration) => {
|
|
if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) {
|
|
return secondsToMilliseconds(duration.seconds);
|
|
}
|
|
// eslint-disable-next-line @gitlab/require-i18n-strings
|
|
throw new Error('Invalid duration: only `seconds` is supported');
|
|
};
|
|
|
|
const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration));
|
|
|
|
const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration));
|
|
|
|
const isValidDuration = (duration) => Boolean(duration && Number.isFinite(duration.seconds));
|
|
|
|
const isValidDateString = (dateString) => {
|
|
if (typeof dateString !== 'string' || !dateString.trim()) {
|
|
return false;
|
|
}
|
|
|
|
let isoFormatted;
|
|
try {
|
|
isoFormatted = dateformat(dateString, 'isoUtcDateTime');
|
|
} catch (e) {
|
|
if (e instanceof TypeError) {
|
|
// not a valid date string
|
|
return false;
|
|
}
|
|
throw e;
|
|
}
|
|
return !Number.isNaN(Date.parse(isoFormatted));
|
|
};
|
|
|
|
const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
|
|
let startDate;
|
|
let endDate;
|
|
|
|
if (direction === DEFAULT_DIRECTION) {
|
|
startDate = minDate;
|
|
endDate = anchorDate;
|
|
} else {
|
|
startDate = anchorDate;
|
|
endDate = maxDate;
|
|
}
|
|
|
|
return {
|
|
startDate,
|
|
endDate,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Converts a fixed range to a fixed range
|
|
* @param {Object} fixedRange - A range with fixed start and
|
|
* end (e.g. "midnight January 1st 2020 to midday January31st 2020")
|
|
*/
|
|
const convertFixedToFixed = ({ start, end }) => ({
|
|
start,
|
|
end,
|
|
});
|
|
|
|
/**
|
|
* Converts an anchored range to a fixed range
|
|
* @param {Object} anchoredRange - A duration of time
|
|
* relative to a fixed point in time (e.g., "the 30 minutes
|
|
* before midnight January 1st 2020", or "the 2 days
|
|
* after midday on the 11th of May 2019")
|
|
*/
|
|
const convertAnchoredToFixed = ({ anchor, duration, direction }) => {
|
|
const anchorDate = new Date(anchor);
|
|
|
|
const { startDate, endDate } = handleRangeDirection({
|
|
minDate: dateMinusDuration(anchorDate, duration),
|
|
maxDate: datePlusDuration(anchorDate, duration),
|
|
direction,
|
|
anchorDate,
|
|
});
|
|
|
|
return {
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Converts a rolling change to a fixed range
|
|
*
|
|
* @param {Object} rollingRange - A time range relative to
|
|
* now (e.g., "last 2 minutes", or "next 2 days")
|
|
*/
|
|
const convertRollingToFixed = ({ duration, direction }) => {
|
|
// Use Date.now internally for easier mocking in tests
|
|
const now = new Date(Date.now());
|
|
|
|
return convertAnchoredToFixed({
|
|
duration,
|
|
direction,
|
|
anchor: now.toISOString(),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Converts an open range to a fixed range
|
|
*
|
|
* @param {Object} openRange - A time range relative
|
|
* to an anchor (e.g., "before midnight on the 1st of
|
|
* January 2020", or "after midday on the 11th of May 2019")
|
|
*/
|
|
const convertOpenToFixed = ({ anchor, direction }) => {
|
|
// Use Date.now internally for easier mocking in tests
|
|
const now = new Date(Date.now());
|
|
|
|
const { startDate, endDate } = handleRangeDirection({
|
|
minDate: MINIMUM_DATE,
|
|
maxDate: now,
|
|
direction,
|
|
anchorDate: new Date(anchor),
|
|
});
|
|
|
|
return {
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Handles invalid date ranges
|
|
*/
|
|
const handleInvalidRange = () => {
|
|
// eslint-disable-next-line @gitlab/require-i18n-strings
|
|
throw new Error('The input range does not have the right format.');
|
|
};
|
|
|
|
const handlers = {
|
|
invalid: handleInvalidRange,
|
|
fixed: convertFixedToFixed,
|
|
anchored: convertAnchoredToFixed,
|
|
rolling: convertRollingToFixed,
|
|
open: convertOpenToFixed,
|
|
};
|
|
|
|
/**
|
|
* Validates and returns the type of range
|
|
*
|
|
* @param {Object} Date time range
|
|
* @returns {String} `key` value for one of the handlers
|
|
*/
|
|
export function getRangeType(range) {
|
|
const { start, end, anchor, duration } = range;
|
|
|
|
if ((start || end) && !anchor && !duration) {
|
|
return isValidDateString(start) && isValidDateString(end)
|
|
? DATETIME_RANGE_TYPES.fixed
|
|
: DATETIME_RANGE_TYPES.invalid;
|
|
}
|
|
if (anchor && duration) {
|
|
return isValidDateString(anchor) && isValidDuration(duration)
|
|
? DATETIME_RANGE_TYPES.anchored
|
|
: DATETIME_RANGE_TYPES.invalid;
|
|
}
|
|
if (duration && !anchor) {
|
|
return isValidDuration(duration) ? DATETIME_RANGE_TYPES.rolling : DATETIME_RANGE_TYPES.invalid;
|
|
}
|
|
if (anchor && !duration) {
|
|
return isValidDateString(anchor) ? DATETIME_RANGE_TYPES.open : DATETIME_RANGE_TYPES.invalid;
|
|
}
|
|
return DATETIME_RANGE_TYPES.invalid;
|
|
}
|
|
|
|
/**
|
|
* convertToFixedRange Transforms a `range of time` into a `fixed range of time`.
|
|
*
|
|
* The following types of a `ranges of time` can be represented:
|
|
*
|
|
* Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020")
|
|
* Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019")
|
|
* Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days")
|
|
* Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019")
|
|
*
|
|
* @param {Object} dateTimeRange - A Time Range representation
|
|
* It contains the data needed to create a fixed time range plus
|
|
* a label (recommended) to indicate the range that is covered.
|
|
*
|
|
* A definition via a TypeScript notation is presented below:
|
|
*
|
|
*
|
|
* type Duration = { // A duration of time, always in seconds
|
|
* seconds: number;
|
|
* }
|
|
*
|
|
* type Direction = 'before' | 'after'; // Direction of time relative to an anchor
|
|
*
|
|
* type FixedRange = {
|
|
* start: ISO8601;
|
|
* end: ISO8601;
|
|
* label: string;
|
|
* }
|
|
*
|
|
* type AnchoredRange = {
|
|
* anchor: ISO8601;
|
|
* duration: Duration;
|
|
* direction: Direction; // defaults to 'before'
|
|
* label: string;
|
|
* }
|
|
*
|
|
* type RollingRange = {
|
|
* duration: Duration;
|
|
* direction: Direction; // defaults to 'before'
|
|
* label: string;
|
|
* }
|
|
*
|
|
* type OpenRange = {
|
|
* anchor: ISO8601;
|
|
* direction: Direction; // defaults to 'before'
|
|
* label: string;
|
|
* }
|
|
*
|
|
* type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange;
|
|
*
|
|
*
|
|
* @returns {FixedRange} An object with a start and end in ISO8601 format.
|
|
*/
|
|
export const convertToFixedRange = (dateTimeRange) =>
|
|
handlers[getRangeType(dateTimeRange)](dateTimeRange);
|
|
|
|
/**
|
|
* Returns a copy of the object only with time range
|
|
* properties relevant to time range calculation.
|
|
*
|
|
* Filtered properties are:
|
|
* - 'start'
|
|
* - 'end'
|
|
* - 'anchor'
|
|
* - 'duration'
|
|
* - 'direction': if direction is already the default, its removed.
|
|
*
|
|
* @param {Object} timeRange - A time range object
|
|
* @returns Copy of time range
|
|
*/
|
|
const pruneTimeRange = (timeRange) => {
|
|
const res = pick(timeRange, ['start', 'end', 'anchor', 'duration', 'direction']);
|
|
if (res.direction === DEFAULT_DIRECTION) {
|
|
return omit(res, 'direction');
|
|
}
|
|
return res;
|
|
};
|
|
|
|
/**
|
|
* Returns true if the time ranges are equal according to
|
|
* the time range calculation properties
|
|
*
|
|
* @param {Object} timeRange - A time range object
|
|
* @param {Object} other - Time range object to compare with.
|
|
* @returns true if the time ranges are equal, false otherwise
|
|
*/
|
|
export const isEqualTimeRanges = (timeRange, other) => {
|
|
const tr1 = pruneTimeRange(timeRange);
|
|
const tr2 = pruneTimeRange(other);
|
|
return isEqual(tr1, tr2);
|
|
};
|
|
|
|
/**
|
|
* Searches for a time range in a array of time ranges using
|
|
* only the properies relevant to time ranges calculation.
|
|
*
|
|
* @param {Object} timeRange - Time range to search (needle)
|
|
* @param {Array} timeRanges - Array of time tanges (haystack)
|
|
*/
|
|
export const findTimeRange = (timeRange, timeRanges) =>
|
|
timeRanges.find((element) => isEqualTimeRanges(element, timeRange));
|
|
|
|
// Time Ranges as URL Parameters Utils
|
|
|
|
/**
|
|
* List of possible time ranges parameters
|
|
*/
|
|
export const timeRangeParamNames = ['start', 'end', 'anchor', 'duration_seconds', 'direction'];
|
|
|
|
/**
|
|
* Converts a valid time range to a flat key-value pairs object.
|
|
*
|
|
* Duration is flatted to avoid having nested objects.
|
|
*
|
|
* @param {Object} A time range
|
|
* @returns key-value pairs object that can be used as parameters in a URL.
|
|
*/
|
|
export const timeRangeToParams = (timeRange) => {
|
|
let params = pruneTimeRange(timeRange);
|
|
if (timeRange.duration) {
|
|
const durationParms = {};
|
|
Object.keys(timeRange.duration).forEach((key) => {
|
|
durationParms[`duration_${key}`] = timeRange.duration[key].toString();
|
|
});
|
|
params = { ...durationParms, ...params };
|
|
params = omit(params, 'duration');
|
|
}
|
|
return params;
|
|
};
|
|
|
|
/**
|
|
* Converts a valid set of flat params to a time range object
|
|
*
|
|
* Parameters that are not part of time range object are ignored.
|
|
*
|
|
* @param {params} params - key-value pairs object.
|
|
*/
|
|
export const timeRangeFromParams = (params) => {
|
|
const timeRangeParams = pick(params, timeRangeParamNames);
|
|
let range = Object.entries(timeRangeParams).reduce((acc, [key, val]) => {
|
|
// unflatten duration
|
|
if (key.startsWith('duration_')) {
|
|
acc.duration = acc.duration || {};
|
|
acc.duration[key.slice('duration_'.length)] = parseInt(val, 10);
|
|
return acc;
|
|
}
|
|
return { [key]: val, ...acc };
|
|
}, {});
|
|
range = pruneTimeRange(range);
|
|
return !isEmpty(range) ? range : null;
|
|
};
|