Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-wasps-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@layerstack/utils': patch
---

fix(Duration): Support `.toISOString()` to output ISO 8601 duration strings (ex. `P2DT3H5M`)
5 changes: 5 additions & 0 deletions .changeset/wicked-items-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@layerstack/utils': patch
---

fix(Duration): Support `fractional` values (ex. `1.5s`). Useful with `minUnits` or `totalUnits`
110 changes: 102 additions & 8 deletions packages/utils/src/lib/duration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, test } from 'vitest';

import { Duration, DurationUnits } from './duration.js';
import { Duration, DurationOption, DurationUnits } from './duration.js';
import { intervalOffset } from './date.js';

describe('Duration', () => {
Expand Down Expand Up @@ -172,12 +172,80 @@ describe('Duration', () => {
expect(actual).equal('1 day and 2 hours and 3 minutes and 4 seconds and 5 milliseconds');
});

it('minUnits', () => {
const duration = new Duration({
duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
});
const actual = duration.format({ minUnits: DurationUnits.Hour });
expect(actual).equal('1d 2h');
describe('options', () => {
test.each([
// Hour (normal, minUnits, fractional)
[{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, {}, '1d 2h 3m 4s 5ms'],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ minUnits: DurationUnits.Hour },
'1d 2h',
],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ minUnits: DurationUnits.Hour, fractional: true },
'1d 2.05h',
],
// Second (normal, minUnits, fractional)
[{ seconds: 1, milliseconds: 500 }, {}, '1s 500ms'],
[{ seconds: 1, milliseconds: 500 }, { minUnits: DurationUnits.Second }, '1s'],
[
{ seconds: 1, milliseconds: 500 },
{ minUnits: DurationUnits.Second, fractional: true },
'1.5s',
],
// Total units
[{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, { totalUnits: 1 }, '1d'],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 2 },
'1d 2h',
],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 3 },
'1d 2h 3m',
],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 4 },
'1d 2h 3m 4s',
],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 5 },
'1d 2h 3m 4s 5ms',
],
// Total units with minUnits
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 4, minUnits: DurationUnits.Minute },
'1d 2h 3m',
],
[
{ days: 0, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 4, minUnits: DurationUnits.Minute },
'2h 3m',
],
// Total units with fractional
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 1, fractional: true },
'1.08d',
],
[
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
{ totalUnits: 4, minUnits: DurationUnits.Minute, fractional: true },
'1d 2h 3.07m',
],
] satisfies Array<[DurationOption, Parameters<Duration['format']>[0], string]>)(
'new Duration({ duration: %s }).format(%s) => %s',
(_duration, options, expected) => {
const duration = new Duration({ duration: _duration });
const actual = duration.format(options);
expect(actual).equal(expected);
}
);
});

it('totalUnits', () => {
Expand All @@ -196,4 +264,30 @@ describe('Duration', () => {
const actual = duration.toString();
expect(actual).equal('1d 2h 3m 4s 5ms');
});

describe('toISOString', () => {
it('basic', () => {
const duration = new Duration({
duration: { years: 1, days: 2, hours: 3, minutes: 4, seconds: 5, milliseconds: 6 },
});
const actual = duration.toISOString();
expect(actual).equal('P1Y2DT3H4M5.6S');
});

it('years only', () => {
const duration = new Duration({
duration: { years: 1 },
});
const actual = duration.toISOString();
expect(actual).equal('P1Y');
});

it('hour only', () => {
const duration = new Duration({
duration: { hours: 1 },
});
const actual = duration.toISOString();
expect(actual).equal('PT1H');
});
});
});
77 changes: 66 additions & 11 deletions packages/utils/src/lib/duration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { parseDate } from './date.js';
import { round } from './number.js';

// TODO: Support months or weeks?

export type DurationOption = {
milliseconds?: number;
Expand Down Expand Up @@ -132,48 +135,79 @@ export class Duration {
options: {
minUnits?: DurationUnits;
totalUnits?: number;
fractional?: boolean;
variant?: 'short' | 'long';
} = {}
) {
const { minUnits, totalUnits = 99, variant = 'short' } = options;
const { minUnits = 99, totalUnits = 99, fractional = false, variant = 'short' } = options;

let sentenceArr = [];

var sentenceArr = [];
var unitNames =
const unitNames =
variant === 'short'
? ['y', 'd', 'h', 'm', 's', 'ms']
: ['years', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'];

var unitNums = [
const unitValues = [
this.years,
this.days,
this.hours,
this.minutes,
this.seconds,
this.milliseconds,
].filter((x, i) => i <= (minUnits ?? 99));
];

const filteredUnitValues = unitValues.filter((x, i) => i <= minUnits);

// Combine unit numbers and names
for (var i in unitNums) {
for (let [i, unitValue] of filteredUnitValues.entries()) {
if (sentenceArr.length >= totalUnits) {
break;
}

const unitNum = unitNums[i];
let unitName = unitNames[i];
const isLastUnit =
i === filteredUnitValues.length - 1 ||
(unitValue !== 0 && sentenceArr.length + 1 >= totalUnits);

if (fractional && isLastUnit) {
// Last unit, add fractional part of next unit
let fraction = 0;
switch (i) {
case DurationUnits.Millisecond:
// No more units
break;
case DurationUnits.Second:
unitValue += round(this.milliseconds / 1000, 2);
break;
case DurationUnits.Minute:
unitValue += round(this.seconds / 60, 2);
break;
case DurationUnits.Hour:
unitValue += round(this.minutes / 60, 2);
break;
case DurationUnits.Day:
unitValue += round(this.hours / 24, 2);
break;
case DurationUnits.Year:
unitValue += round(this.days / 365, 2);
break;
}
}

// Hide `0` values unless last unit (and none shown before)
if (unitNum !== 0 || (sentenceArr.length === 0 && Number(i) === unitNums.length - 1)) {
if (unitValue !== 0 || (sentenceArr.length === 0 && isLastUnit)) {
switch (variant) {
case 'short':
sentenceArr.push(unitNum + unitName);
sentenceArr.push(unitValue + unitName);
break;

case 'long':
if (unitNum === 1) {
if (unitValue === 1) {
// Trim off plural `s`
unitName = unitName.slice(0, -1);
}
sentenceArr.push(unitNum + ' ' + unitName);
sentenceArr.push(unitValue + ' ' + unitName);
break;
}
}
Expand All @@ -186,4 +220,25 @@ export class Duration {
toString() {
return this.format();
}

/**
* Returns the ISO 8601 duration string representation of the duration.
* @returns ISO 8601 duration string (e.g. "P3Y6M4DT12H30M5S")
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations
*/
toISOString() {
let str = 'P';
if (this.#years) str += this.#years + 'Y';
if (this.#days) str += this.#days + 'D';
if (this.#hours || this.#minutes || this.#seconds || this.#milliseconds) str += 'T';
if (this.#hours) str += this.#hours + 'H';
if (this.#minutes) str += this.#minutes + 'M';
if (this.#seconds || this.#milliseconds) {
str += this.#seconds;
if (this.#milliseconds) str += '.' + this.#milliseconds;
str += 'S';
}
if (str === 'P') str = 'PT0S'; // zero duration
return str;
}
}
Loading