Commit 7cc93836 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'chronic-duration-js' into 'master'

Frontend: Port gitlab_chronic_duration to JavaScript

See merge request gitlab-org/gitlab!73402
parents 2ac8faae 314a6a12
/*
* NOTE:
* Changes to this file should be kept in sync with
* https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/lib/gitlab_chronic_duration.rb.
*/
export class DurationParseError extends Error {}
// On average, there's a little over 4 weeks in month.
const FULL_WEEKS_PER_MONTH = 4;
const HOURS_PER_DAY = 24;
const DAYS_PER_MONTH = 30;
const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g;
const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'];
const MAPPINGS = {
seconds: 'seconds',
second: 'seconds',
secs: 'seconds',
sec: 'seconds',
s: 'seconds',
minutes: 'minutes',
minute: 'minutes',
mins: 'minutes',
min: 'minutes',
m: 'minutes',
hours: 'hours',
hour: 'hours',
hrs: 'hours',
hr: 'hours',
h: 'hours',
days: 'days',
day: 'days',
dy: 'days',
d: 'days',
weeks: 'weeks',
week: 'weeks',
wks: 'weeks',
wk: 'weeks',
w: 'weeks',
months: 'months',
mo: 'months',
mos: 'months',
month: 'months',
years: 'years',
year: 'years',
yrs: 'years',
yr: 'years',
y: 'years',
};
const JOIN_WORDS = ['and', 'with', 'plus'];
function convertToNumber(string) {
const f = parseFloat(string);
return f % 1 > 0 ? f : parseInt(string, 10);
}
function durationUnitsSecondsMultiplier(unit, opts) {
if (!DURATION_UNITS_LIST.includes(unit)) {
return 0;
}
const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);
switch (unit) {
case 'years':
return 31557600;
case 'months':
return 3600 * hoursPerDay * daysPerMonth;
case 'weeks':
return 3600 * hoursPerDay * daysPerWeek;
case 'days':
return 3600 * hoursPerDay;
case 'hours':
return 3600;
case 'minutes':
return 60;
case 'seconds':
return 1;
default:
return 0;
}
}
function calculateFromWords(string, opts) {
let val = 0;
const words = string.split(' ');
words.forEach((v, k) => {
if (v === '') {
return;
}
if (v.search(FLOAT_MATCHER) >= 0) {
val +=
convertToNumber(v) *
durationUnitsSecondsMultiplier(
words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds',
opts,
);
}
});
return val;
}
// Parse 3:41:59 and return 3 hours 41 minutes 59 seconds
function filterByType(string) {
const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks');
if (
string
.replace(/ +/g, '')
.search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0
) {
const res = [];
string
.replace(/ +/g, '')
.split(':')
.reverse()
.forEach((v, k) => {
if (!chronoUnitsList[k]) {
return;
}
res.push(`${v} ${chronoUnitsList[k]}`);
});
return res.reverse().join(' ');
}
return string;
}
// Get rid of unknown words and map found
// words to defined time units
function filterThroughWhiteList(string, opts) {
const res = [];
string.split(' ').forEach((word) => {
if (word === '') {
return;
}
if (word.search(FLOAT_MATCHER) >= 0) {
res.push(word.trim());
return;
}
const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, '');
if (MAPPINGS[strippedWord] !== undefined) {
res.push(MAPPINGS[strippedWord]);
} else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) {
throw new DurationParseError(
`An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`,
);
}
});
// add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec'
if (res.length > 0 && MAPPINGS[res[0]]) {
res.splice(0, 0, 1);
}
return res.join(' ');
}
function cleanup(string, opts) {
let res = string.toLowerCase();
/*
* TODO The Ruby implementation of this algorithm uses the Numerizer module,
* which converts strings like "forty two" to "42", but there is no
* JavaScript equivalent of Numerizer. Skip it for now until Numerizer is
* ported to JavaScript.
*/
res = filterByType(res);
res = res
.replace(FLOAT_MATCHER, (n) => ` ${n} `)
.replace(/ +/g, ' ')
.trim();
return filterThroughWhiteList(res, opts);
}
function humanizeTimeUnit(number, unit, pluralize, keepZero) {
if (number === '0' && !keepZero) {
return null;
}
let res = number + unit;
// A poor man's pluralizer
if (number !== '1' && pluralize) {
res += 's';
}
return res;
}
// Given a string representation of elapsed time,
// return an integer (or float, if fractions of a
// second are input)
export function parseChronicDuration(string, opts = {}) {
const result = calculateFromWords(cleanup(string, opts), opts);
return !opts.keepZero && result === 0 ? null : result;
}
// Given an integer and an optional format,
// returns a formatted string representing elapsed time
export function outputChronicDuration(seconds, opts = {}) {
const units = {
years: 0,
months: 0,
weeks: 0,
days: 0,
hours: 0,
minutes: 0,
seconds,
};
const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);
const decimalPlaces =
seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null;
const minute = 60;
const hour = 60 * minute;
const day = hoursPerDay * hour;
const month = daysPerMonth * day;
const year = 31557600;
if (units.seconds >= 31557600 && units.seconds % year < units.seconds % month) {
units.years = Math.trunc(units.seconds / year);
units.months = Math.trunc((units.seconds % year) / month);
units.days = Math.trunc(((units.seconds % year) % month) / day);
units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour);
units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute);
units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute);
} else if (seconds >= 60) {
units.minutes = Math.trunc(seconds / 60);
units.seconds %= 60;
if (units.minutes >= 60) {
units.hours = Math.trunc(units.minutes / 60);
units.minutes = Math.trunc(units.minutes % 60);
if (!opts.limitToHours) {
if (units.hours >= hoursPerDay) {
units.days = Math.trunc(units.hours / hoursPerDay);
units.hours = Math.trunc(units.hours % hoursPerDay);
if (opts.weeks) {
if (units.days >= daysPerWeek) {
units.weeks = Math.trunc(units.days / daysPerWeek);
units.days = Math.trunc(units.days % daysPerWeek);
if (units.weeks >= FULL_WEEKS_PER_MONTH) {
units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH);
units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH);
}
}
} else if (units.days >= daysPerMonth) {
units.months = Math.trunc(units.days / daysPerMonth);
units.days = Math.trunc(units.days % daysPerMonth);
}
}
}
}
}
let joiner = opts.joiner || ' ';
let process = null;
let dividers;
switch (opts.format) {
case 'micro':
dividers = {
years: 'y',
months: 'mo',
weeks: 'w',
days: 'd',
hours: 'h',
minutes: 'm',
seconds: 's',
};
joiner = '';
break;
case 'short':
dividers = {
years: 'y',
months: 'mo',
weeks: 'w',
days: 'd',
hours: 'h',
minutes: 'm',
seconds: 's',
};
break;
case 'long':
dividers = {
/* eslint-disable @gitlab/require-i18n-strings */
years: ' year',
months: ' month',
weeks: ' week',
days: ' day',
hours: ' hour',
minutes: ' minute',
seconds: ' second',
/* eslint-enable @gitlab/require-i18n-strings */
pluralize: true,
};
break;
case 'chrono':
dividers = {
years: ':',
months: ':',
weeks: ':',
days: ':',
hours: ':',
minutes: ':',
seconds: ':',
keepZero: true,
};
process = (str) => {
// Pad zeros
// Get rid of lead off times if they are zero
// Get rid of lead off zero
// Get rid of trailing:
const divider = ':';
const processed = [];
str.split(divider).forEach((n) => {
if (n === '') {
return;
}
// add zeros only if n is an integer
if (n.search('\\.') >= 0) {
processed.push(
parseFloat(n)
.toFixed(decimalPlaces)
.padStart(3 + decimalPlaces, '0'),
);
} else {
processed.push(n.padStart(2, '0'));
}
});
return processed
.join(divider)
.replace(/^(00:)+/g, '')
.replace(/^0/g, '')
.replace(/:$/g, '');
};
joiner = '';
break;
default:
dividers = {
/* eslint-disable @gitlab/require-i18n-strings */
years: ' yr',
months: ' mo',
weeks: ' wk',
days: ' day',
hours: ' hr',
minutes: ' min',
seconds: ' sec',
/* eslint-enable @gitlab/require-i18n-strings */
pluralize: true,
};
break;
}
let result = [];
['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => {
if (t === 'weeks' && !opts.weeks) {
return;
}
let num = units[t];
if (t === 'seconds' && num % 0 !== 0) {
num = num.toFixed(decimalPlaces);
} else {
num = num.toString();
}
const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero;
const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero);
if (humanized !== null) {
result.push(humanized);
}
});
if (opts.units) {
result = result.slice(0, opts.units);
}
result = result.join(joiner);
if (process) {
result = process(result);
}
return result.length === 0 ? null : result;
}
/*
* NOTE:
* Changes to this file should be kept in sync with
* https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/spec/lib/chronic_duration_spec.rb.
*/
import {
parseChronicDuration,
outputChronicDuration,
DurationParseError,
} from '~/chronic_duration';
describe('parseChronicDuration', () => {
/*
* TODO The Ruby implementation of this algorithm uses the Numerizer module,
* which converts strings like "forty two" to "42", but there is no
* JavaScript equivalent of Numerizer. Skip it for now until Numerizer is
* ported to JavaScript.
*/
const EXEMPLARS = {
'1:20': 60 + 20,
'1:20.51': 60 + 20.51,
'4:01:01': 4 * 3600 + 60 + 1,
'3 mins 4 sec': 3 * 60 + 4,
'3 Mins 4 Sec': 3 * 60 + 4,
// 'three mins four sec': 3 * 60 + 4,
'2 hrs 20 min': 2 * 3600 + 20 * 60,
'2h20min': 2 * 3600 + 20 * 60,
'6 mos 1 day': 6 * 30 * 24 * 3600 + 24 * 3600,
'1 year 6 mos 1 day': 1 * 31557600 + 6 * 30 * 24 * 3600 + 24 * 3600,
'2.5 hrs': 2.5 * 3600,
'47 yrs 6 mos and 4.5d': 47 * 31557600 + 6 * 30 * 24 * 3600 + 4.5 * 24 * 3600,
// 'two hours and twenty minutes': 2 * 3600 + 20 * 60,
// 'four hours and forty minutes': 4 * 3600 + 40 * 60,
// 'four hours, and fourty minutes': 4 * 3600 + 40 * 60,
'3 weeks and, 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2,
'3 weeks, plus 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2,
'3 weeks with 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2,
'1 month': 3600 * 24 * 30,
'2 months': 3600 * 24 * 30 * 2,
'18 months': 3600 * 24 * 30 * 18,
'1 year 6 months': 3600 * 24 * (365.25 + 6 * 30),
day: 3600 * 24,
'minute 30s': 90,
};
describe("when string can't be parsed", () => {
it('returns null', () => {
expect(parseChronicDuration('gobblygoo')).toBeNull();
});
it('cannot parse zero', () => {
expect(parseChronicDuration('0')).toBeNull();
});
describe('when .raiseExceptions set to true', () => {
it('raises with DurationParseError', () => {
expect(() => parseChronicDuration('23 gobblygoos', { raiseExceptions: true })).toThrowError(
DurationParseError,
);
});
it('does not raise when string is empty', () => {
expect(parseChronicDuration('', { raiseExceptions: true })).toBeNull();
});
});
});
it('should return zero if the string parses as zero and the .keepZero option is true', () => {
expect(parseChronicDuration('0', { keepZero: true })).toBe(0);
});
it('should return a float if seconds are in decimals', () => {
expect(parseChronicDuration('12 mins 3.141 seconds')).toBeCloseTo(723.141, 4);
});
it('should return an integer unless the seconds are in decimals', () => {
expect(parseChronicDuration('12 mins 3 seconds')).toBe(723);
});
it('should be able to parse minutes by default', () => {
expect(parseChronicDuration('5', { defaultUnit: 'minutes' })).toBe(300);
});
Object.entries(EXEMPLARS).forEach(([k, v]) => {
it(`parses a duration like ${k}`, () => {
expect(parseChronicDuration(k)).toBe(v);
});
});
describe('with .hoursPerDay and .daysPerMonth params', () => {
it('uses provided .hoursPerDay', () => {
expect(parseChronicDuration('1d', { hoursPerDay: 24 })).toBe(24 * 60 * 60);
expect(parseChronicDuration('1d', { hoursPerDay: 8 })).toBe(8 * 60 * 60);
});
it('uses provided .daysPerMonth', () => {
expect(parseChronicDuration('1mo', { daysPerMonth: 30 })).toBe(30 * 24 * 60 * 60);
expect(parseChronicDuration('1mo', { daysPerMonth: 20 })).toBe(20 * 24 * 60 * 60);
expect(parseChronicDuration('1w', { daysPerMonth: 30 })).toBe(7 * 24 * 60 * 60);
expect(parseChronicDuration('1w', { daysPerMonth: 20 })).toBe(5 * 24 * 60 * 60);
});
it('uses provided both .hoursPerDay and .daysPerMonth', () => {
expect(parseChronicDuration('1mo', { daysPerMonth: 30, hoursPerDay: 24 })).toBe(
30 * 24 * 60 * 60,
);
expect(parseChronicDuration('1mo', { daysPerMonth: 20, hoursPerDay: 8 })).toBe(
20 * 8 * 60 * 60,
);
expect(parseChronicDuration('1w', { daysPerMonth: 30, hoursPerDay: 24 })).toBe(
7 * 24 * 60 * 60,
);
expect(parseChronicDuration('1w', { daysPerMonth: 20, hoursPerDay: 8 })).toBe(
5 * 8 * 60 * 60,
);
});
});
});
describe('outputChronicDuration', () => {
const EXEMPLARS = {
[60 + 20]: {
micro: '1m20s',
short: '1m 20s',
default: '1 min 20 secs',
long: '1 minute 20 seconds',
chrono: '1:20',
},
[60 + 20.51]: {
micro: '1m20.51s',
short: '1m 20.51s',
default: '1 min 20.51 secs',
long: '1 minute 20.51 seconds',
chrono: '1:20.51',
},
[60 + 20.51928]: {
micro: '1m20.51928s',
short: '1m 20.51928s',
default: '1 min 20.51928 secs',
long: '1 minute 20.51928 seconds',
chrono: '1:20.51928',
},
[4 * 3600 + 60 + 1]: {
micro: '4h1m1s',
short: '4h 1m 1s',
default: '4 hrs 1 min 1 sec',
long: '4 hours 1 minute 1 second',
chrono: '4:01:01',
},
[2 * 3600 + 20 * 60]: {
micro: '2h20m',
short: '2h 20m',
default: '2 hrs 20 mins',
long: '2 hours 20 minutes',
chrono: '2:20',
},
[2 * 3600 + 20 * 60]: {
micro: '2h20m',
short: '2h 20m',
default: '2 hrs 20 mins',
long: '2 hours 20 minutes',
chrono: '2:20:00',
},
[6 * 30 * 24 * 3600 + 24 * 3600]: {
micro: '6mo1d',
short: '6mo 1d',
default: '6 mos 1 day',
long: '6 months 1 day',
chrono: '6:01:00:00:00', // Yuck. FIXME
},
[365.25 * 24 * 3600 + 24 * 3600]: {
micro: '1y1d',
short: '1y 1d',
default: '1 yr 1 day',
long: '1 year 1 day',
chrono: '1:00:01:00:00:00',
},
[3 * 365.25 * 24 * 3600 + 24 * 3600]: {
micro: '3y1d',
short: '3y 1d',
default: '3 yrs 1 day',
long: '3 years 1 day',
chrono: '3:00:01:00:00:00',
},
[3600 * 24 * 30 * 18]: {
micro: '18mo',
short: '18mo',
default: '18 mos',
long: '18 months',
chrono: '18:00:00:00:00',
},
};
Object.entries(EXEMPLARS).forEach(([k, v]) => {
const kf = parseFloat(k);
Object.entries(v).forEach(([key, val]) => {
it(`properly outputs a duration of ${kf} seconds as ${val} using the ${key} format option`, () => {
expect(outputChronicDuration(kf, { format: key })).toBe(val);
});
});
});
const KEEP_ZERO_EXEMPLARS = {
true: {
micro: '0s',
short: '0s',
default: '0 secs',
long: '0 seconds',
chrono: '0',
},
'': {
micro: null,
short: null,
default: null,
long: null,
chrono: '0',
},
};
Object.entries(KEEP_ZERO_EXEMPLARS).forEach(([k, v]) => {
const kb = Boolean(k);
Object.entries(v).forEach(([key, val]) => {
it(`should properly output a duration of 0 seconds as ${val} using the ${key} format option, if the .keepZero option is ${kb}`, () => {
expect(outputChronicDuration(0, { format: key, keepZero: kb })).toBe(val);
});
});
});
it('returns weeks when needed', () => {
expect(outputChronicDuration(45 * 24 * 60 * 60, { weeks: true })).toMatch(/.*wk.*/);
});
it('returns hours and minutes only when .limitToHours option specified', () => {
expect(outputChronicDuration(395 * 24 * 60 * 60 + 15 * 60, { limitToHours: true })).toBe(
'9480 hrs 15 mins',
);
});
describe('with .hoursPerDay and .daysPerMonth params', () => {
it('uses provided .hoursPerDay', () => {
expect(outputChronicDuration(24 * 60 * 60, { hoursPerDay: 24 })).toBe('1 day');
expect(outputChronicDuration(24 * 60 * 60, { hoursPerDay: 8 })).toBe('3 days');
});
it('uses provided .daysPerMonth', () => {
expect(outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 30 })).toBe(
'1 wk',
);
expect(outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 20 })).toBe(
'1 wk 2 days',
);
});
it('uses provided both .hoursPerDay and .daysPerMonth', () => {
expect(
outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 30, hoursPerDay: 24 }),
).toBe('1 wk');
expect(
outputChronicDuration(5 * 8 * 60 * 60, { weeks: true, daysPerMonth: 20, hoursPerDay: 8 }),
).toBe('1 wk');
});
it('uses provided params alongside with .weeks when converting to months', () => {
expect(outputChronicDuration(30 * 24 * 60 * 60, { daysPerMonth: 30, hoursPerDay: 24 })).toBe(
'1 mo',
);
expect(
outputChronicDuration(30 * 24 * 60 * 60, {
daysPerMonth: 30,
hoursPerDay: 24,
weeks: true,
}),
).toBe('1 mo 2 days');
expect(outputChronicDuration(20 * 8 * 60 * 60, { daysPerMonth: 20, hoursPerDay: 8 })).toBe(
'1 mo',
);
expect(
outputChronicDuration(20 * 8 * 60 * 60, { daysPerMonth: 20, hoursPerDay: 8, weeks: true }),
).toBe('1 mo');
});
});
it('returns the specified number of units if provided', () => {
expect(outputChronicDuration(4 * 3600 + 60 + 1, { units: 2 })).toBe('4 hrs 1 min');
expect(
outputChronicDuration(6 * 30 * 24 * 3600 + 24 * 3600 + 3600 + 60 + 1, {
units: 3,
format: 'long',
}),
).toBe('6 months 1 day 1 hour');
});
describe('when the format is not specified', () => {
it('uses the default format', () => {
expect(outputChronicDuration(2 * 3600 + 20 * 60)).toBe('2 hrs 20 mins');
});
});
Object.entries(EXEMPLARS).forEach(([seconds, formatSpec]) => {
const secondsF = parseFloat(seconds);
Object.keys(formatSpec).forEach((format) => {
it(`outputs a duration for ${seconds} that parses back to the same thing when using the ${format} format`, () => {
expect(parseChronicDuration(outputChronicDuration(secondsF, { format }))).toBe(secondsF);
});
});
});
it('uses user-specified joiner if provided', () => {
expect(outputChronicDuration(2 * 3600 + 20 * 60, { joiner: ', ' })).toBe('2 hrs, 20 mins');
});
});
describe('work week', () => {
it('should parse knowing the work week', () => {
const week = parseChronicDuration('5d', { hoursPerDay: 8, daysPerMonth: 20 });
expect(parseChronicDuration('40h', { hoursPerDay: 8, daysPerMonth: 20 })).toBe(week);
expect(parseChronicDuration('1w', { hoursPerDay: 8, daysPerMonth: 20 })).toBe(week);
});
});
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