Skip to content

Commit 73c58ed

Browse files
authored
Duration format features (.toISOString(), .format({ fractional: true })) (#92)
* fix(Duration): Support `.toISOString()` to output ISO 8601 duration strings (ex. `P2DT3H5M`) * fix(Duration): Support `fractional` values (ex. `1.5s`). Useful with `minUnits` or `totalUnits`
1 parent d43c010 commit 73c58ed

File tree

4 files changed

+178
-19
lines changed

4 files changed

+178
-19
lines changed

.changeset/empty-wasps-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@layerstack/utils': patch
3+
---
4+
5+
fix(Duration): Support `.toISOString()` to output ISO 8601 duration strings (ex. `P2DT3H5M`)

.changeset/wicked-items-change.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@layerstack/utils': patch
3+
---
4+
5+
fix(Duration): Support `fractional` values (ex. `1.5s`). Useful with `minUnits` or `totalUnits`

packages/utils/src/lib/duration.test.ts

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, test } from 'vitest';
22

3-
import { Duration, DurationUnits } from './duration.js';
3+
import { Duration, DurationOption, DurationUnits } from './duration.js';
44
import { intervalOffset } from './date.js';
55

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

175-
it('minUnits', () => {
176-
const duration = new Duration({
177-
duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
178-
});
179-
const actual = duration.format({ minUnits: DurationUnits.Hour });
180-
expect(actual).equal('1d 2h');
175+
describe('options', () => {
176+
test.each([
177+
// Hour (normal, minUnits, fractional)
178+
[{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, {}, '1d 2h 3m 4s 5ms'],
179+
[
180+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
181+
{ minUnits: DurationUnits.Hour },
182+
'1d 2h',
183+
],
184+
[
185+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
186+
{ minUnits: DurationUnits.Hour, fractional: true },
187+
'1d 2.05h',
188+
],
189+
// Second (normal, minUnits, fractional)
190+
[{ seconds: 1, milliseconds: 500 }, {}, '1s 500ms'],
191+
[{ seconds: 1, milliseconds: 500 }, { minUnits: DurationUnits.Second }, '1s'],
192+
[
193+
{ seconds: 1, milliseconds: 500 },
194+
{ minUnits: DurationUnits.Second, fractional: true },
195+
'1.5s',
196+
],
197+
// Total units
198+
[{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, { totalUnits: 1 }, '1d'],
199+
[
200+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
201+
{ totalUnits: 2 },
202+
'1d 2h',
203+
],
204+
[
205+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
206+
{ totalUnits: 3 },
207+
'1d 2h 3m',
208+
],
209+
[
210+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
211+
{ totalUnits: 4 },
212+
'1d 2h 3m 4s',
213+
],
214+
[
215+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
216+
{ totalUnits: 5 },
217+
'1d 2h 3m 4s 5ms',
218+
],
219+
// Total units with minUnits
220+
[
221+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
222+
{ totalUnits: 4, minUnits: DurationUnits.Minute },
223+
'1d 2h 3m',
224+
],
225+
[
226+
{ days: 0, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
227+
{ totalUnits: 4, minUnits: DurationUnits.Minute },
228+
'2h 3m',
229+
],
230+
// Total units with fractional
231+
[
232+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
233+
{ totalUnits: 1, fractional: true },
234+
'1.08d',
235+
],
236+
[
237+
{ days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 },
238+
{ totalUnits: 4, minUnits: DurationUnits.Minute, fractional: true },
239+
'1d 2h 3.07m',
240+
],
241+
] satisfies Array<[DurationOption, Parameters<Duration['format']>[0], string]>)(
242+
'new Duration({ duration: %s }).format(%s) => %s',
243+
(_duration, options, expected) => {
244+
const duration = new Duration({ duration: _duration });
245+
const actual = duration.format(options);
246+
expect(actual).equal(expected);
247+
}
248+
);
181249
});
182250

183251
it('totalUnits', () => {
@@ -196,4 +264,30 @@ describe('Duration', () => {
196264
const actual = duration.toString();
197265
expect(actual).equal('1d 2h 3m 4s 5ms');
198266
});
267+
268+
describe('toISOString', () => {
269+
it('basic', () => {
270+
const duration = new Duration({
271+
duration: { years: 1, days: 2, hours: 3, minutes: 4, seconds: 5, milliseconds: 6 },
272+
});
273+
const actual = duration.toISOString();
274+
expect(actual).equal('P1Y2DT3H4M5.6S');
275+
});
276+
277+
it('years only', () => {
278+
const duration = new Duration({
279+
duration: { years: 1 },
280+
});
281+
const actual = duration.toISOString();
282+
expect(actual).equal('P1Y');
283+
});
284+
285+
it('hour only', () => {
286+
const duration = new Duration({
287+
duration: { hours: 1 },
288+
});
289+
const actual = duration.toISOString();
290+
expect(actual).equal('PT1H');
291+
});
292+
});
199293
});

packages/utils/src/lib/duration.ts

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { parseDate } from './date.js';
2+
import { round } from './number.js';
3+
4+
// TODO: Support months or weeks?
25

36
export type DurationOption = {
47
milliseconds?: number;
@@ -132,48 +135,79 @@ export class Duration {
132135
options: {
133136
minUnits?: DurationUnits;
134137
totalUnits?: number;
138+
fractional?: boolean;
135139
variant?: 'short' | 'long';
136140
} = {}
137141
) {
138-
const { minUnits, totalUnits = 99, variant = 'short' } = options;
142+
const { minUnits = 99, totalUnits = 99, fractional = false, variant = 'short' } = options;
143+
144+
let sentenceArr = [];
139145

140-
var sentenceArr = [];
141-
var unitNames =
146+
const unitNames =
142147
variant === 'short'
143148
? ['y', 'd', 'h', 'm', 's', 'ms']
144149
: ['years', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'];
145150

146-
var unitNums = [
151+
const unitValues = [
147152
this.years,
148153
this.days,
149154
this.hours,
150155
this.minutes,
151156
this.seconds,
152157
this.milliseconds,
153-
].filter((x, i) => i <= (minUnits ?? 99));
158+
];
159+
160+
const filteredUnitValues = unitValues.filter((x, i) => i <= minUnits);
154161

155162
// Combine unit numbers and names
156-
for (var i in unitNums) {
163+
for (let [i, unitValue] of filteredUnitValues.entries()) {
157164
if (sentenceArr.length >= totalUnits) {
158165
break;
159166
}
160167

161-
const unitNum = unitNums[i];
162168
let unitName = unitNames[i];
169+
const isLastUnit =
170+
i === filteredUnitValues.length - 1 ||
171+
(unitValue !== 0 && sentenceArr.length + 1 >= totalUnits);
172+
173+
if (fractional && isLastUnit) {
174+
// Last unit, add fractional part of next unit
175+
let fraction = 0;
176+
switch (i) {
177+
case DurationUnits.Millisecond:
178+
// No more units
179+
break;
180+
case DurationUnits.Second:
181+
unitValue += round(this.milliseconds / 1000, 2);
182+
break;
183+
case DurationUnits.Minute:
184+
unitValue += round(this.seconds / 60, 2);
185+
break;
186+
case DurationUnits.Hour:
187+
unitValue += round(this.minutes / 60, 2);
188+
break;
189+
case DurationUnits.Day:
190+
unitValue += round(this.hours / 24, 2);
191+
break;
192+
case DurationUnits.Year:
193+
unitValue += round(this.days / 365, 2);
194+
break;
195+
}
196+
}
163197

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

171205
case 'long':
172-
if (unitNum === 1) {
206+
if (unitValue === 1) {
173207
// Trim off plural `s`
174208
unitName = unitName.slice(0, -1);
175209
}
176-
sentenceArr.push(unitNum + ' ' + unitName);
210+
sentenceArr.push(unitValue + ' ' + unitName);
177211
break;
178212
}
179213
}
@@ -186,4 +220,25 @@ export class Duration {
186220
toString() {
187221
return this.format();
188222
}
223+
224+
/**
225+
* Returns the ISO 8601 duration string representation of the duration.
226+
* @returns ISO 8601 duration string (e.g. "P3Y6M4DT12H30M5S")
227+
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations
228+
*/
229+
toISOString() {
230+
let str = 'P';
231+
if (this.#years) str += this.#years + 'Y';
232+
if (this.#days) str += this.#days + 'D';
233+
if (this.#hours || this.#minutes || this.#seconds || this.#milliseconds) str += 'T';
234+
if (this.#hours) str += this.#hours + 'H';
235+
if (this.#minutes) str += this.#minutes + 'M';
236+
if (this.#seconds || this.#milliseconds) {
237+
str += this.#seconds;
238+
if (this.#milliseconds) str += '.' + this.#milliseconds;
239+
str += 'S';
240+
}
241+
if (str === 'P') str = 'PT0S'; // zero duration
242+
return str;
243+
}
189244
}

0 commit comments

Comments
 (0)