Commit f9ffb242 authored by Mark Florian's avatar Mark Florian

Merge branch '197879-time-ranges-data-structure' into 'master'

Adds a format for relative and open date ranges

See merge request gitlab-org/gitlab!23584
parents 3ee4aa9c 11c5e561
import dateformat from 'dateformat';
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/i18n/no-non-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;
}
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
dateformat(dateString, 'isoUtcDateTime');
return true;
} catch (e) {
return false;
}
};
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/i18n/no-non-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) ? 'fixed' : 'invalid';
}
if (anchor && duration) {
return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid';
}
if (duration && !anchor) {
return isValidDuration(duration) ? 'rolling' : 'invalid';
}
if (anchor && !duration) {
return isValidDateString(anchor) ? 'open' : 'invalid';
}
return '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);
import _ from 'lodash';
import { getRangeType, convertToFixedRange } from '~/lib/utils/datetime_range';
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
const MOCK_NOW_ISO_STRING = new Date(MOCK_NOW).toISOString();
describe('Date time range utils', () => {
describe('getRangeType', () => {
it('infers correctly the range type from the input object', () => {
const rangeTypes = {
fixed: [{ start: MOCK_NOW_ISO_STRING, end: MOCK_NOW_ISO_STRING }],
anchored: [{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 0 } }],
rolling: [{ duration: { seconds: 0 } }],
open: [{ anchor: MOCK_NOW_ISO_STRING }],
invalid: [
{},
{ start: MOCK_NOW_ISO_STRING },
{ end: MOCK_NOW_ISO_STRING },
{ start: 'NOT_A_DATE', end: 'NOT_A_DATE' },
{ duration: { seconds: 'NOT_A_NUMBER' } },
{ duration: { seconds: Infinity } },
{ duration: { minutes: 20 } },
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 'NOT_A_NUMBER' } },
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: Infinity } },
{ junk: 'exists' },
],
};
Object.entries(rangeTypes).forEach(([type, examples]) => {
examples.forEach(example => expect(getRangeType(example)).toEqual(type));
});
});
});
describe('convertToFixedRange', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
});
afterEach(() => {
Date.now.mockRestore();
});
describe('When a fixed range is input', () => {
const defaultFixedRange = {
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-31T23:59:00.000Z',
label: 'January 2020',
};
const mockFixedRange = params => ({ ...defaultFixedRange, ...params });
it('converts a fixed range to an equal fixed range', () => {
const aFixedRange = mockFixedRange();
expect(convertToFixedRange(aFixedRange)).toEqual({
start: defaultFixedRange.start,
end: defaultFixedRange.end,
});
});
it('throws an error when fixed range does not contain an end time', () => {
const aFixedRangeMissingEnd = _.omit(mockFixedRange(), 'end');
expect(() => convertToFixedRange(aFixedRangeMissingEnd)).toThrow();
});
it('throws an error when fixed range does not contain a start time', () => {
const aFixedRangeMissingStart = _.omit(mockFixedRange(), 'start');
expect(() => convertToFixedRange(aFixedRangeMissingStart)).toThrow();
});
it('throws an error when the dates cannot be parsed', () => {
const wrongStart = mockFixedRange({ start: 'I_CANNOT_BE_PARSED' });
const wrongEnd = mockFixedRange({ end: 'I_CANNOT_BE_PARSED' });
expect(() => convertToFixedRange(wrongStart)).toThrow();
expect(() => convertToFixedRange(wrongEnd)).toThrow();
});
});
describe('When an anchored range is input', () => {
const defaultAnchoredRange = {
anchor: '2020-01-01T00:00:00.000Z',
direction: 'after',
duration: {
seconds: 60 * 2,
},
label: 'First two minutes of 2020',
};
const mockAnchoredRange = params => ({ ...defaultAnchoredRange, ...params });
it('converts to a fixed range', () => {
const anAnchoredRange = mockAnchoredRange();
expect(convertToFixedRange(anAnchoredRange)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-01T00:02:00.000Z',
});
});
it('converts to a fixed range with a `before` direction', () => {
const anAnchoredRange = mockAnchoredRange({ direction: 'before' });
expect(convertToFixedRange(anAnchoredRange)).toEqual({
start: '2019-12-31T23:58:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('converts to a fixed range without an explicit direction, defaulting to `before`', () => {
const anAnchoredRange = _.omit(mockAnchoredRange(), 'direction');
expect(convertToFixedRange(anAnchoredRange)).toEqual({
start: '2019-12-31T23:58:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('throws an error when the anchor cannot be parsed', () => {
const wrongAnchor = mockAnchoredRange({ anchor: 'I_CANNOT_BE_PARSED' });
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
describe('when a rolling range is input', () => {
it('converts to a fixed range', () => {
const aRollingRange = {
direction: 'after',
duration: {
seconds: 60 * 2,
},
label: 'Next 2 minutes',
};
expect(convertToFixedRange(aRollingRange)).toEqual({
start: '2020-01-23T20:00:00.000Z',
end: '2020-01-23T20:02:00.000Z',
});
});
it('converts to a fixed range with an implicit `before` direction', () => {
const aRollingRangeWithNoDirection = {
duration: {
seconds: 60 * 2,
},
label: 'Last 2 minutes',
};
expect(convertToFixedRange(aRollingRangeWithNoDirection)).toEqual({
start: '2020-01-23T19:58:00.000Z',
end: '2020-01-23T20:00:00.000Z',
});
});
it('throws an error when the duration is not in the right format', () => {
const wrongDuration = {
direction: 'before',
duration: {
minutes: 20,
},
label: 'Last 20 minutes',
};
expect(() => convertToFixedRange(wrongDuration)).toThrow();
});
it('throws an error when the anchor is not valid', () => {
const wrongAnchor = {
anchor: 'CAN_T_PARSE_THIS',
direction: 'after',
label: '2020 so far',
};
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
describe('when an open range is input', () => {
it('converts to a fixed range with an `after` direction', () => {
const soFar2020 = {
anchor: '2020-01-01T00:00:00.000Z',
direction: 'after',
label: '2020 so far',
};
expect(convertToFixedRange(soFar2020)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-23T20:00:00.000Z',
});
});
it('converts to a fixed range with the explicit `before` direction', () => {
const before2020 = {
anchor: '2020-01-01T00:00:00.000Z',
direction: 'before',
label: 'Before 2020',
};
expect(convertToFixedRange(before2020)).toEqual({
start: '1970-01-01T00:00:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('converts to a fixed range with the implicit `before` direction', () => {
const alsoBefore2020 = {
anchor: '2020-01-01T00:00:00.000Z',
label: 'Before 2020',
};
expect(convertToFixedRange(alsoBefore2020)).toEqual({
start: '1970-01-01T00:00:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('throws an error when the anchor cannot be parsed', () => {
const wrongAnchor = {
anchor: 'CAN_T_PARSE_THIS',
direction: 'after',
label: '2020 so far',
};
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment