diff --git a/.changeset/quick-ducks-do.md b/.changeset/quick-ducks-do.md new file mode 100644 index 0000000..db563f6 --- /dev/null +++ b/.changeset/quick-ducks-do.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': major +--- + +breaking: Replace `getDuration()` / `humanizeDuration()` utils with `Duration` class (with `.format()` method) diff --git a/packages/utils/src/lib/duration.test.ts b/packages/utils/src/lib/duration.test.ts new file mode 100644 index 0000000..e2f8d77 --- /dev/null +++ b/packages/utils/src/lib/duration.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; + +import { Duration, DurationUnits } from './duration.js'; +import { PeriodType } from './date_types.js'; +import { testDate } from './date.test.js'; +import { subDays } from 'date-fns'; + +describe('Duration', () => { + it('default', () => { + const actual = new Duration(); + expect(actual.years).equal(0); + expect(actual.days).equal(0); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(0); + expect(actual.milliseconds).equal(0); + }); + + it('start/end range with strings', () => { + const actual = new Duration({ start: '2025-05-19', end: '2025-05-20' }); + expect(actual.years).equal(0); + expect(actual.days).equal(1); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(0); + expect(actual.milliseconds).equal(0); + }); + + it('start/end range with Date objects', () => { + const actual = new Duration({ start: new Date('2025-05-19'), end: new Date('2025-05-20') }); + expect(actual.years).equal(0); + expect(actual.days).equal(1); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(0); + expect(actual.milliseconds).equal(0); + }); + + it('start-only should use `now` for end', () => { + const start = subDays(new Date(), 10); + const actual = new Duration({ start }); + expect(actual.years).equal(0); + expect(actual.days).equal(10); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(0); + // expect(actual.milliseconds).equal(0); // Ignoring just in case test timing is off + }); + + it('duration option', () => { + const actual = new Duration({ duration: { seconds: 10 } }); + expect(actual.years).equal(0); + expect(actual.days).equal(0); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(10); + expect(actual.milliseconds).equal(0); + }); + + it('duration option with carryover seconds', () => { + const actual = new Duration({ duration: { seconds: 90 } }); + expect(actual.years).equal(0); + expect(actual.days).equal(0); + expect(actual.hours).equal(0); + expect(actual.minutes).equal(1); + expect(actual.seconds).equal(30); + expect(actual.milliseconds).equal(0); + }); + + it('duration option with carryover minutes', () => { + const actual = new Duration({ duration: { minutes: 90 } }); + expect(actual.years).equal(0); + expect(actual.days).equal(0); + expect(actual.hours).equal(1); + expect(actual.minutes).equal(30); + expect(actual.seconds).equal(0); + expect(actual.milliseconds).equal(0); + }); + + it('duration option with carryover hours', () => { + const actual = new Duration({ duration: { hours: 30 } }); + expect(actual.years).equal(0); + expect(actual.days).equal(1); + expect(actual.hours).equal(6); + expect(actual.minutes).equal(0); + expect(actual.seconds).equal(0); + expect(actual.milliseconds).equal(0); + }); + + it('duration comparison with explicit duration', () => { + const duration1 = new Duration({ duration: { seconds: 10 } }); + const duration2 = new Duration({ duration: { seconds: 11 } }); + + expect(duration1 < duration2).equal(true); + expect(duration2 > duration1).equal(true); + expect(duration1 != duration2).equal(true); + }); + + it('duration comparison with dates', () => { + const duration1 = new Duration({ start: '2025-05-19', end: '2025-05-20' }); + const duration2 = new Duration({ start: '2025-05-19', end: '2025-05-21' }); + + expect(duration1 < duration2).equal(true); + expect(duration2 > duration1).equal(true); + }); + + it('duration comparison with dates and duration', () => { + const duration = new Duration({ start: '2025-05-19', end: '2025-05-20' }); + + const durationSame = new Duration({ duration: { days: 1 } }); + // TODO: Why is `valueOf()` not called implicitly? + // expect(duration == durationSame).equal(true); + expect(duration.valueOf() == durationSame.valueOf()).equal(true); + + const durationMore = new Duration({ duration: { days: 2 } }); + expect(duration < durationMore).equal(true); + + const durationLess = new Duration({ duration: { hours: 3 } }); + expect(duration > durationLess).equal(true); + }); + + it('toJSON', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const json = duration.toJSON(); + expect(json).eql({ + years: 0, + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + milliseconds: 5, + }); + }); + + it('toJSON', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const string = JSON.stringify(duration); + expect(string).eql('{"years":0,"days":1,"hours":2,"minutes":3,"seconds":4,"milliseconds":5}'); + }); + + it('reconstruct from JSON', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const json = JSON.parse(JSON.stringify(duration)); + const duration2 = new Duration({ duration: json }); + + expect(duration2.years).equal(0); + expect(duration2.days).equal(1); + expect(duration2.hours).equal(2); + expect(duration2.minutes).equal(3); + expect(duration2.seconds).equal(4); + expect(duration2.milliseconds).equal(5); + }); + + describe('format', () => { + it('default', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const actual = duration.format(); + expect(actual).equal('1d 2h 3m 4s 5ms'); + }); + + it('long variant', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const actual = duration.format({ variant: 'long' }); + 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'); + }); + + it('totalUnits', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const actual = duration.format({ totalUnits: 3 }); + expect(actual).equal('1d 2h 3m'); + }); + }); + + it('toString', () => { + const duration = new Duration({ + duration: { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }, + }); + const actual = duration.toString(); + expect(actual).equal('1d 2h 3m 4s 5ms'); + }); +}); diff --git a/packages/utils/src/lib/duration.ts b/packages/utils/src/lib/duration.ts index cf06969..0fcc8a2 100644 --- a/packages/utils/src/lib/duration.ts +++ b/packages/utils/src/lib/duration.ts @@ -1,12 +1,12 @@ import { parseISO } from 'date-fns'; -export type Duration = { - milliseconds: number; - seconds: number; - minutes: number; - hours: number; - days: number; - years: number; +export type DurationOption = { + milliseconds?: number; + seconds?: number; + minutes?: number; + hours?: number; + days?: number; + years?: number; }; export enum DurationUnits { @@ -17,137 +17,173 @@ export enum DurationUnits { Second, Millisecond, } -// export enum DurationUnits { -// Millisecond = 1, -// Second = 1000 * Millisecond, -// Minute = 60 * Second, -// Hour = 60 * Minute, -// Day = 24 * Hour, -// Year = 365 * Day, -// } - -export function getDuration( - start?: Date | string, - end?: Date | string | null, - duration?: Partial -): Duration | null { - const startDate = typeof start === 'string' ? parseISO(start) : start; - const endDate = typeof end === 'string' ? parseISO(end) : end; - - const differenceInMs = startDate - ? Math.abs(Number(endDate || new Date()) - Number(startDate)) - : undefined; - - if (!Number.isFinite(differenceInMs) && duration == null) { - return null; + +export class Duration { + #milliseconds = 0; + #seconds = 0; + #minutes = 0; + #hours = 0; + #days = 0; + #years = 0; + + constructor( + options: { + start?: Date | string; + end?: Date | string | null; + duration?: DurationOption; + } = {} + ) { + const startDate = typeof options.start === 'string' ? parseISO(options.start) : options.start; + const endDate = typeof options.end === 'string' ? parseISO(options.end) : options.end; + + const differenceInMs = startDate + ? Math.abs(Number(endDate || new Date()) - Number(startDate)) + : undefined; + + if (!Number.isFinite(differenceInMs) && options.duration == null) { + return; + } + + this.#milliseconds = options.duration?.milliseconds ?? differenceInMs ?? 0; + this.#seconds = options.duration?.seconds ?? 0; + this.#minutes = options.duration?.minutes ?? 0; + this.#hours = options.duration?.hours ?? 0; + this.#days = options.duration?.days ?? 0; + this.#years = options.duration?.years ?? 0; + + if (this.#milliseconds >= 1000) { + const carrySeconds = (this.#milliseconds - (this.#milliseconds % 1000)) / 1000; + this.#seconds += carrySeconds; + this.#milliseconds = this.#milliseconds - carrySeconds * 1000; + } + + if (this.#seconds >= 60) { + const carryMinutes = (this.#seconds - (this.#seconds % 60)) / 60; + this.#minutes += carryMinutes; + this.#seconds = this.#seconds - carryMinutes * 60; + } + + if (this.#minutes >= 60) { + const carryHours = (this.#minutes - (this.#minutes % 60)) / 60; + this.#hours += carryHours; + this.#minutes = this.#minutes - carryHours * 60; + } + + if (this.#hours >= 24) { + const carryDays = (this.#hours - (this.#hours % 24)) / 24; + this.#days += carryDays; + this.#hours = this.#hours - carryDays * 24; + } + + if (this.#days >= 365) { + const carryYears = (this.#days - (this.#days % 365)) / 365; + this.#years += carryYears; + this.#days = this.#days - carryYears * 365; + } } - var milliseconds = duration?.milliseconds ?? differenceInMs ?? 0; - var seconds = duration?.seconds ?? 0; - var minutes = duration?.minutes ?? 0; - var hours = duration?.hours ?? 0; - var days = duration?.days ?? 0; - var years = duration?.years ?? 0; - - if (milliseconds >= 1000) { - const carrySeconds = (milliseconds - (milliseconds % 1000)) / 1000; - seconds += carrySeconds; - milliseconds = milliseconds - carrySeconds * 1000; + get years() { + return this.#years; } - if (seconds >= 60) { - const carryMinutes = (seconds - (seconds % 60)) / 60; - minutes += carryMinutes; - seconds = seconds - carryMinutes * 60; + get days() { + return this.#days; } - if (minutes >= 60) { - const carryHours = (minutes - (minutes % 60)) / 60; - hours += carryHours; - minutes = minutes - carryHours * 60; + get hours() { + return this.#hours; } - if (hours >= 24) { - const carryDays = (hours - (hours % 24)) / 24; - days += carryDays; - hours = hours - carryDays * 24; + get minutes() { + return this.#minutes; } - if (days >= 365) { - const carryYears = (days - (days % 365)) / 365; - years += carryYears; - days = days - carryYears * 365; + get seconds() { + return this.#seconds; } - return { - milliseconds, - seconds, - minutes, - hours, - days, - years, - }; -} + get milliseconds() { + return this.#milliseconds; + } -// See also: https://stackoverflow.com/questions/19700283/how-to-convert-time-milliseconds-to-hours-min-sec-format-in-javascript/33909506 -export function humanizeDuration(config: { - start?: Date | string; - end?: Date | string | null; - duration?: Partial; - minUnits?: DurationUnits; - totalUnits?: number; - variant?: 'short' | 'long'; -}) { - const { start, end, minUnits, totalUnits = 99, variant = 'short' } = config; - - const duration = getDuration(start, end, config.duration); - - if (duration === null) { - return 'unknown'; + valueOf() { + return ( + this.#milliseconds + + this.#seconds * 1000 + + this.#minutes * 60 * 1000 + + this.#hours * 60 * 60 * 1000 + + this.#days * 24 * 60 * 60 * 1000 + + this.#years * 365 * 24 * 60 * 60 * 1000 + ); } - var sentenceArr = []; - var unitNames = - variant === 'short' - ? ['y', 'd', 'h', 'm', 's', 'ms'] - : ['years', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']; - - var unitNums = [ - duration.years, - duration.days, - duration.hours, - duration.minutes, - duration.seconds, - duration.milliseconds, - ].filter((x, i) => i <= (minUnits ?? 99)); - - // Combine unit numbers and names - for (var i in unitNums) { - if (sentenceArr.length >= totalUnits) { - break; - } + toJSON() { + return { + years: this.#years, + days: this.#days, + hours: this.#hours, + minutes: this.#minutes, + seconds: this.#seconds, + milliseconds: this.#milliseconds, + }; + } + + format( + options: { + minUnits?: DurationUnits; + totalUnits?: number; + variant?: 'short' | 'long'; + } = {} + ) { + const { minUnits, totalUnits = 99, variant = 'short' } = options; + + var sentenceArr = []; + var unitNames = + variant === 'short' + ? ['y', 'd', 'h', 'm', 's', 'ms'] + : ['years', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']; + + var unitNums = [ + this.years, + this.days, + this.hours, + this.minutes, + this.seconds, + this.milliseconds, + ].filter((x, i) => i <= (minUnits ?? 99)); + + // Combine unit numbers and names + for (var i in unitNums) { + if (sentenceArr.length >= totalUnits) { + break; + } - const unitNum = unitNums[i]; - let unitName = unitNames[i]; - - // Hide `0` values unless last unit (and none shown before) - if (unitNum !== 0 || (sentenceArr.length === 0 && Number(i) === unitNums.length - 1)) { - switch (variant) { - case 'short': - sentenceArr.push(unitNum + unitName); - break; - - case 'long': - if (unitNum === 1) { - // Trim off plural `s` - unitName = unitName.slice(0, -1); - } - sentenceArr.push(unitNum + ' ' + unitName); - break; + const unitNum = unitNums[i]; + let unitName = unitNames[i]; + + // Hide `0` values unless last unit (and none shown before) + if (unitNum !== 0 || (sentenceArr.length === 0 && Number(i) === unitNums.length - 1)) { + switch (variant) { + case 'short': + sentenceArr.push(unitNum + unitName); + break; + + case 'long': + if (unitNum === 1) { + // Trim off plural `s` + unitName = unitName.slice(0, -1); + } + sentenceArr.push(unitNum + ' ' + unitName); + break; + } } } + + const sentence = sentenceArr.join(variant === 'long' ? ' and ' : ' '); + return sentence; } - const sentence = sentenceArr.join(variant === 'long' ? ' and ' : ' '); - return sentence; + toString() { + return this.format(); + } } diff --git a/packages/utils/src/lib/index.ts b/packages/utils/src/lib/index.ts index a7912b2..133019c 100644 --- a/packages/utils/src/lib/index.ts +++ b/packages/utils/src/lib/index.ts @@ -4,7 +4,7 @@ export { formatDate, getDateFuncsByPeriodType } from './date.js'; export { PeriodType, DayOfWeek, DateToken } from './date_types.js'; export * from './date_types.js'; export * from './dom.js'; -export { getDuration, humanizeDuration, DurationUnits } from './duration.js'; +export { Duration, DurationUnits } from './duration.js'; export * from './file.js'; export { format, diff --git a/sites/docs/src/routes/_NavMenu.svelte b/sites/docs/src/routes/_NavMenu.svelte index 32d3958..9c21390 100644 --- a/sites/docs/src/routes/_NavMenu.svelte +++ b/sites/docs/src/routes/_NavMenu.svelte @@ -48,7 +48,7 @@ const table = ['actions', 'stores']; const tailwind = ['utils']; - const utils = ['duration', 'format', 'json', 'Logger', 'string']; + const utils = ['Duration', 'format', 'json', 'Logger', 'string']; diff --git a/sites/docs/src/routes/docs/utils/+page.server.ts b/sites/docs/src/routes/docs/utils/+page.server.ts index 5b0bdc3..b5737e8 100644 --- a/sites/docs/src/routes/docs/utils/+page.server.ts +++ b/sites/docs/src/routes/docs/utils/+page.server.ts @@ -1,5 +1,5 @@ import { redirect } from '@sveltejs/kit'; export async function load({ url }) { - redirect(302, '/docs/utils/duration'); + redirect(302, '/docs/utils/Duration'); } diff --git a/sites/docs/src/routes/docs/utils/duration/+page.svelte b/sites/docs/src/routes/docs/utils/duration/+page.svelte index 76df32b..7bbfcc7 100644 --- a/sites/docs/src/routes/docs/utils/duration/+page.svelte +++ b/sites/docs/src/routes/docs/utils/duration/+page.svelte @@ -3,47 +3,55 @@ import Preview from '$docs/Preview.svelte'; - import { getDuration, humanizeDuration, DurationUnits } from '@layerstack/utils'; + import { Duration, DurationUnits } from '@layerstack/utils';

Examples

-

humanizeDuration()

+

new Duration()

-

Date

+

Date range

- -
{humanizeDuration({ start: subDays(new Date(), 3) })}
-
{humanizeDuration({ start: subMonths(new Date(), 3) })}
-
{humanizeDuration({ start: subMonths(new Date(), 3), variant: 'long' })}
+ +
{JSON.stringify(new Duration({ start: subDays(new Date(), 3) }), null, 2)}
-

string

+

Date range

+ + +
{JSON.stringify(new Duration({ start: '2021-01-01', end: '2021-01-07' }), null, 2)}
+
+ +

duration.format()

+ +

Date range

-
{humanizeDuration({ start: '1982-03-30' })}
-
{humanizeDuration({ start: '2021-01-01', end: '2021-01-07' })}
-
{humanizeDuration({ start: '1982-03-30', totalUnits: 2 })}
-
{humanizeDuration({ start: '1982-03-30', minUnits: DurationUnits.Hour })}
+
{new Duration({ start: subDays(new Date(), 3) }).format()}
+
{new Duration({ start: subMonths(new Date(), 3) }).format()}
+
{new Duration({ start: subMonths(new Date(), 3) }).format({ variant: 'long' })}
-

duration object

+

string range

-
{humanizeDuration({ duration: { milliseconds: 300 } })}
-
{humanizeDuration({ duration: { hours: 1, minutes: 30 } })}
-
{humanizeDuration({ duration: { days: 5, hours: 26 } })}
+
{new Duration({ start: '1982-03-30' }).format()}
+
{new Duration({ start: '2021-01-01', end: '2021-01-07' }).format()}
+
{new Duration({ start: '1982-03-30' }).format({ totalUnits: 2 })}
+
{new Duration({ start: '1982-03-30' }).format({ minUnits: DurationUnits.Hour })}
-

Leap year comparison

+

duration object

-
{humanizeDuration({ start: new Date('2023-02-28'), end: new Date('2023-03-01') })}
-
{humanizeDuration({ start: new Date('2024-02-28'), end: new Date('2024-03-01') })}
+
{new Duration({ duration: { milliseconds: 300 } }).format()}
+
{new Duration({ duration: { hours: 1, minutes: 30 } }).format()}
+
{new Duration({ duration: { days: 5, hours: 26 } }).format()}
-

getDuration()

+

Leap year comparison

- - {JSON.stringify(getDuration(subDays(new Date(), 3)), null, 2)} + +
{new Duration({ start: new Date('2023-02-28'), end: new Date('2023-03-01') }).format()}
+
{new Duration({ start: new Date('2024-02-28'), end: new Date('2024-03-01') }).format()}