atomic-fns
Version:
Like Lodash, but for ESNext and with types. Stop shipping code built for browsers from 2015.
393 lines (392 loc) • 12.3 kB
JavaScript
import { pick } from '../collections/index.js';
import { isNumber, isObject, ValueError } from '../globals/index.js';
import { round } from '../math/index.js';
import { parseISODuration } from './date/utils.js';
const lowOrderMatrix = {
weeks: {
days: 7,
hours: 7 * 24,
minutes: 7 * 24 * 60,
seconds: 7 * 24 * 60 * 60,
milliseconds: 7 * 24 * 60 * 60 * 1000
},
days: {
hours: 24,
minutes: 24 * 60,
seconds: 24 * 60 * 60,
milliseconds: 24 * 60 * 60 * 1000
},
hours: { minutes: 60, seconds: 60 * 60, milliseconds: 60 * 60 * 1000 },
minutes: { seconds: 60, milliseconds: 60 * 1000 },
seconds: { milliseconds: 1000 }
};
const casualMatrix = {
years: {
quarters: 4,
months: 12,
weeks: 52,
days: 365,
hours: 365 * 24,
minutes: 365 * 24 * 60,
seconds: 365 * 24 * 60 * 60,
milliseconds: 365 * 24 * 60 * 60 * 1000
},
quarters: {
months: 3,
weeks: 13,
days: 91,
hours: 91 * 24,
minutes: 91 * 24 * 60,
seconds: 91 * 24 * 60 * 60,
milliseconds: 91 * 24 * 60 * 60 * 1000
},
months: {
weeks: 4,
days: 30,
hours: 30 * 24,
minutes: 30 * 24 * 60,
seconds: 30 * 24 * 60 * 60,
milliseconds: 30 * 24 * 60 * 60 * 1000
},
...lowOrderMatrix
};
const exactDaysInYear = 146097.0 / 400;
const exactDaysInMonth = 146097.0 / 4800;
const exactMatrix = {
years: {
quarters: 4,
months: 12,
weeks: exactDaysInYear / 7,
days: exactDaysInYear,
hours: exactDaysInYear * 24,
minutes: exactDaysInYear * 24 * 60,
seconds: exactDaysInYear * 24 * 60 * 60,
milliseconds: exactDaysInYear * 24 * 60 * 60 * 1000
},
quarters: {
months: 3,
weeks: exactDaysInYear / 28,
days: exactDaysInYear / 4,
hours: (exactDaysInYear * 24) / 4,
minutes: (exactDaysInYear * 24 * 60) / 4,
seconds: (exactDaysInYear * 24 * 60 * 60) / 4,
milliseconds: (exactDaysInYear * 24 * 60 * 60 * 1000) / 4
},
months: {
weeks: exactDaysInMonth / 7,
days: exactDaysInMonth,
hours: exactDaysInMonth * 24,
minutes: exactDaysInMonth * 24 * 60,
seconds: exactDaysInMonth * 24 * 60 * 60,
milliseconds: exactDaysInMonth * 24 * 60 * 60 * 1000
},
...lowOrderMatrix
};
export const UNITS_PLURAL = {
year: 'years',
years: 'years',
quarter: 'quarters',
quarters: 'quarters',
month: 'months',
months: 'months',
week: 'weeks',
weeks: 'weeks',
day: 'days',
days: 'days',
hour: 'hours',
hours: 'hours',
minute: 'minutes',
minutes: 'minutes',
second: 'seconds',
seconds: 'seconds',
millisecond: 'milliseconds',
milliseconds: 'milliseconds'
};
const orderedUnits = [
'years',
'quarters',
'months',
'weeks',
'days',
'hours',
'minutes',
'seconds',
'milliseconds'
];
const reverseUnits = orderedUnits.slice(0).reverse();
export class Duration {
values;
matrix = casualMatrix;
constructor(dur = {}, exact = false) {
if (dur instanceof Duration) {
this.values = { ...dur.values };
}
else if (isNumber(dur)) {
this.values = { milliseconds: dur };
}
else if (isObject(dur)) {
this.values = normalizeUnits(dur);
}
if (exact) {
// Set the conversion matrix to use
this.matrix = exactMatrix;
}
}
/** Get the years. */
get years() {
return this.values.years || 0;
}
/** Get the months. */
get months() {
return this.values.months || 0;
}
/** Get the weeks. */
get weeks() {
return this.values.weeks || 0;
}
/** Get the days. */
get days() {
return this.values.days || 0;
}
/** Get the hours. */
get hours() {
return this.values.hours || 0;
}
/** et the minutes. */
get minutes() {
return this.values.minutes || 0;
}
/** Get the seconds. */
get seconds() {
return this.values.seconds || 0;
}
/** Get the milliseconds. */
get milliseconds() {
return this.values.milliseconds || 0;
}
/**
* Get the value of unit.
* @param {string} unit - a unit such as 'minute' or 'day'
* @example Duration.fromObject({years: 2, days: 3}).get('years') // 2
* @example Duration.fromObject({years: 2, days: 3}).get('months') // 0
* @example Duration.fromObject({years: 2, days: 3}).get('days') // 3
* @return {number}
*/
get(unit) {
return this[UNITS_PLURAL[unit.toLowerCase()]];
}
add(duration) {
const dur = new Duration(duration);
const result = {};
for (const k of orderedUnits) {
if (dur.values[k] || this.values[k]) {
result[k] = dur.get(k) + this.get(k);
}
}
return new Duration(result);
}
/**
* Make this Duration shorter by the specified amount. Return a newly-constructed Duration.
* @param {Duration|Object|number} duration - The amount to subtract.
* @return {Duration}
*/
subtract(duration) {
const dur = new Duration(duration);
return this.add(dur.negated());
}
/**
* Return the negative of this Duration.
* @example
```js
new Duration({ hours: 1, seconds: 30 }).negated().toObject()
// { hours: -1, seconds: -30 }
```
* @returns {Duration}
*/
negated() {
const negated = {};
for (const k of Object.keys(this.values)) {
negated[k] = this.values[k] === 0 ? 0 : -this.values[k];
}
return new Duration(negated);
}
/**
* Return the absolute values of this Duration.
* @example
```js
new Duration({ hours: 1, seconds: -30 }).abs().toObject()
// { hours: 1, seconds: 30 }
```
* @returns {Duration}
*/
abs() {
const parts = {};
for (const k of Object.keys(this.values)) {
parts[k] = Math.abs(this.values[k]);
}
return new Duration(parts);
}
shiftToAll() {
return this.shiftTo('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds');
}
shiftTo(...units) {
if (units.length === 0) {
return this;
}
units = units.map((u) => UNITS_PLURAL[u.toLowerCase()]);
const built = {};
const accumulated = {};
const vals = this.toObject();
let lastUnit;
for (const toUnit of orderedUnits) {
if (units.includes(toUnit)) {
lastUnit = toUnit;
let own = 0;
// anything we haven't boiled down yet should get boiled to this unit
for (const fromUnit in accumulated) {
own += accumulated[fromUnit] * this.matrix[fromUnit][toUnit];
accumulated[fromUnit] = 0;
}
// plus anything that's already in this unit
if (typeof vals[toUnit] === 'number') {
own += vals[toUnit];
}
const i = Math.trunc(own);
built[toUnit] = i;
accumulated[toUnit] = (own * 1000 - i * 1000) / 1000;
// plus anything further down the chain that should be rolled up in to this
for (const fromUnit in vals) {
if (orderedUnits.indexOf(fromUnit) > orderedUnits.indexOf(toUnit)) {
convert(this.matrix, vals, fromUnit, built, toUnit);
}
}
}
else if (isNumber(vals[toUnit])) {
accumulated[toUnit] = vals[toUnit];
}
}
// anything leftover becomes the decimal for the last unit
// lastUnit must be defined since units is not empty
for (const key in accumulated) {
if (accumulated[key] !== 0) {
built[lastUnit] +=
key === lastUnit ? accumulated[key] : accumulated[key] / this.matrix[lastUnit][key];
}
}
return new Duration(built);
}
total(unit) {
return this.shiftTo(unit).get(unit);
}
exact(unit) {
this.matrix = exactMatrix;
if (!unit)
return this;
return this.total(unit);
}
toObject() {
return { ...this.values };
}
normalize() {
normalizeValues(this.matrix, this.values);
return new Duration(this.values);
}
rescale() {
const dur = this.normalize().shiftToAll();
const vals = removeZeroes(dur.values);
return new Duration(vals);
}
/**
* Returns an ISO 8601-compliant string representation of this Duration.
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations
* @example Duration.fromObject({ years: 3, seconds: 45 }).toString() // 'P3YT45S'
* @example Duration.fromObject({ months: 4, seconds: 45 }).toString() // 'P4MT45S'
* @example Duration.fromObject({ months: 5 }).toString() // 'P5M'
* @example Duration.fromObject({ minutes: 5 }).toString() // 'PT5M'
* @example Duration.fromObject({ milliseconds: 6 }).toString() // 'PT0.006S'
* @return {string}
*/
toISOString() {
let s = 'P';
if (this.years !== 0)
s += this.years + 'Y';
if (this.months !== 0)
s += this.months + 'M';
if (this.weeks !== 0)
s += this.weeks + 'W';
if (this.days !== 0)
s += this.days + 'D';
if (this.hours !== 0 || this.minutes !== 0 || this.seconds !== 0 || this.milliseconds !== 0)
s += 'T';
if (this.hours !== 0)
s += this.hours + 'H';
if (this.minutes !== 0)
s += this.minutes + 'M';
if (this.seconds !== 0 || this.milliseconds !== 0)
// this will handle "floating point madness" by removing extra decimal places
// https://stackoverflow.com/questions/588004/is-floating-point-math-broken
s += round(this.seconds + this.milliseconds / 1000, 3) + 'S';
if (s === 'P')
s += 'T0S';
return s;
}
toString() {
return this.toISOString();
}
/**
* Create a Duration from an ISO 8601 duration string.
*
* @example
```js
Duration.fromISO('P3Y6M1W4DT12H30M5S').toObject()
// { years: 3, months: 6, weeks: 1, days: 4, hours: 12, minutes: 30, seconds: 5 }
Duration.fromISO('PT23H').toObject()
// { hours: 23 }
Duration.fromISO('P5Y3M').toObject()
// { years: 5, months: 3 }
```
* @return {Duration}
*/
static fromISO(text) {
const [parsed] = parseISODuration(text);
if (parsed)
return new Duration(parsed);
throw new ValueError(`The input "${text}" can't be parsed as ISO 8601`);
}
}
function normalizeUnits(obj) {
const vals = {};
for (const unit of Object.keys(obj)) {
vals[UNITS_PLURAL[unit]] = obj[unit];
}
return vals;
}
function antiTrunc(n) {
return n < 0 ? Math.floor(n) : Math.ceil(n);
}
// NB: mutates parameters
function convert(matrix, sources, fromUnit, result, toUnit) {
const conv = matrix[toUnit][fromUnit];
const raw = sources[fromUnit] / conv;
const sameSign = Math.sign(raw) === Math.sign(result[toUnit]);
// ok, so this is wild, but see the matrix in the tests
const added = !sameSign && result[toUnit] !== 0 && Math.abs(raw) <= 1 ? antiTrunc(raw) : Math.trunc(raw);
result[toUnit] += added;
sources[fromUnit] -= added * conv;
}
// NB: mutates parameters
function normalizeValues(matrix, vals) {
reverseUnits.reduce((previous, current) => {
if (vals[current]) {
if (previous) {
convert(matrix, vals, previous, vals, current);
}
return current;
}
return previous;
}, null);
}
// Remove all properties with a value of 0 from an object
function removeZeroes(vals) {
return pick(vals, (value) => value !== 0);
}