From 64cfe83abd1450210501348bad7c62c17aca73b4 Mon Sep 17 00:00:00 2001 From: Ethan Brown Date: Mon, 18 May 2026 12:30:26 -0700 Subject: [PATCH 1/4] Refactor after PR feedback. After Marcelo and I finally agreed upon an API, this implements that. Notes: To support this in a robust/consistent manner, the following methods were also added: - `Duration#asWeeks` - `Duration#toPartsArray` (in addition to being usefully internally, this will be useful to anyone wishing to do more custom duration formatting, providing the parts in an ordered array with associated unit labels) Note that this also changes the API of `parts` in a backwards-compatible way: when called without arguments, parts returns what it always has, but now a `largestUnits` argument can be provided which accomodates for weeks (and makes space for future expansion, e.g. years). --- .gitignore | 3 + core/src/time.ts | 162 ++++++++++++++++++++++++++++++++++++-- core/tests/suites/time.ts | 66 ++++++++++++++++ 3 files changed, 223 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 6c333c3..ab9be27 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ coverage/ # Temporary files *.tmp .cache/ + +# for folks who use Wakatime for time-in-editor insights +.wakatime-project diff --git a/core/src/time.ts b/core/src/time.ts index 1d88faf..a9dae07 100644 --- a/core/src/time.ts +++ b/core/src/time.ts @@ -202,6 +202,36 @@ class TimeOfDay { } } +/** + * Configuration strings for duration formatting. + */ +const DURATION_FORMAT_CONFIG = { + long: { + suffixes: { + weeks: { singular: " week", plural: " weeks" }, + days: { singular: " day", plural: " days" }, + hours: { singular: " hour", plural: " hours" }, + minutes: { singular: " minute", plural: " minutes" }, + seconds: { singular: " second", plural: " seconds" }, + milliseconds: { singular: " millisecond", plural: " milliseconds" }, + }, + less_than: "less than ", + separator: ", ", + }, + short: { + suffixes: { + weeks: { singular: "w", plural: "w" }, + days: { singular: "d", plural: "d" }, + hours: { singular: "h", plural: "h" }, + minutes: { singular: "m", plural: "m" }, + seconds: { singular: "s", plural: "s" }, + milliseconds: { singular: "ms", plural: "ms" }, + }, + less_than: "< ", + separator: " ", + }, +} as const; + /** A length of time. */ class Duration { private constructor(private readonly millis: number) {} @@ -226,6 +256,10 @@ class Duration { return Duration.hours(n * 24); } + static weeks(n: number): Duration { + return Duration.hours(n * 24 * 7); + } + asMilliseconds(): number { return this.millis; } @@ -246,6 +280,10 @@ class Duration { return this.millis / Duration.days(1).millis; } + asWeeks(): number { + return this.millis / Duration.weeks(1).millis; + } + add(other: Duration): Duration { return new Duration(this.millis + other.millis); } @@ -298,22 +336,82 @@ class Duration { return new Duration(Math.abs(this.millis)); } - /** Parts as absolute numbers. */ + /** + * Absolute value of duration broken into units (where the duration equals the sum of these units). + * To preserve a backwards-compatible API, an option for `largestUnits` is provided that defaults + * to "days". See also Duration#toPartsArray(). + */ parts(): { days: number; hours: number; minutes: number; seconds: number; milliseconds: number; + }; + parts(options: { largestUnits: "weeks" }): { + weeks: number; + days: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; + }; + parts(options: { largestUnits: "days" | "weeks" } = { largestUnits: "days" }): { + weeks?: number; + days: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; } { const d = new Duration(Math.abs(this.millis)); - return { - days: Math.floor(d.asDays()), - hours: Math.floor(d.asHours()) % 24, - minutes: Math.floor(d.asMinutes()) % 60, - seconds: Math.floor(d.asSeconds()) % 60, - milliseconds: Math.floor(d.asMilliseconds() % 1_000), - }; + switch (options.largestUnits) { + case "days": { + return { + days: Math.floor(d.asDays()), + hours: Math.floor(d.asHours()) % 24, + minutes: Math.floor(d.asMinutes()) % 60, + seconds: Math.floor(d.asSeconds()) % 60, + milliseconds: Math.floor(d.asMilliseconds() % 1_000), + }; + } + case "weeks": { + return { + weeks: Math.floor(d.asWeeks()), + days: Math.floor(d.asDays()) % 7, + hours: Math.floor(d.asHours()) % 24, + minutes: Math.floor(d.asMinutes()) % 60, + seconds: Math.floor(d.asSeconds()) % 60, + milliseconds: Math.floor(d.asMilliseconds() % 1_000), + }; + } + default: { + throw new Error(`invalid largest unit: ${options.largestUnits as never}`); + } + } + } + + /** + * Returns duration broken into units as an array, ordered from largest to smallest unit. + * See also Duration#parts. + */ + toPartsArray(): [ + [number, units: "weeks"], + [number, units: "days"], + [number, units: "hours"], + [number, units: "minutes"], + [number, units: "seconds"], + [number, units: "milliseconds"], + ] { + const { weeks, days, hours, minutes, seconds, milliseconds } = this.parts({ largestUnits: "weeks" }); + return [ + [weeks, "weeks"], + [days, "days"], + [hours, "hours"], + [minutes, "minutes"], + [seconds, "seconds"], + [milliseconds, "milliseconds"], + ]; } /** Formats duration as ISO-8601 string (e.g., "P1DT2H30M45.123S"). */ @@ -335,6 +433,54 @@ class Duration { return prefix + dayPart + (hasTimePart ? "T" + timeParts : ""); } + /** + * Formats a duration to a friendly, human readable string. First argument (`verbosity`) can be + * either "short" (e.g. 1w 3d 5h 2m 0s 20ms) or long (e.g. 1 week, 3 days, 5 hours, 2 minutes, 20 milliseconds). + * + * Options allow for truncation after minutes or seconds. Additionally, if the truncated duration would + * evaluate to zero but not _exactly_ zero (e.g. a duration of 20 seconds when truncated to minutes will + * result in 0m), there is an option `show_less_than_when_close_to_zero` which will prefix either "<" (short) + * or "less than" long in this case, which is recommended when users are actively "watching the clock". + */ + toFormatted( + verbosity: "short" | "long", + options: { + truncateAfter?: "minutes" | "seconds"; + onTruncation?: "show_less_than_when_close_to_zero"; + } = {}, + ) { + const parts = this.toPartsArray(); + const { suffixes, less_than, separator } = DURATION_FORMAT_CONFIG[verbosity]; + const formattedParts = parts.map(([n, units]) => ({ + n, + s: n.toString() + (n === 1 ? suffixes[units].singular : suffixes[units].plural), + units, + truncateAfter: options.truncateAfter === units, + })); + const truncateIdx = formattedParts.findIndex(x => x.truncateAfter); + // determine if there's a remainder after any truncation + const hasRemainder = truncateIdx > -1 && formattedParts.slice(truncateIdx + 1).some(x => x.n !== 0); + // perform truncation + if (truncateIdx > 0) formattedParts.splice(truncateIdx + 1); + // remove leading zeros (ensuring there's one unit left) + while (formattedParts.length > 1 && formattedParts[0]?.n === 0) formattedParts.shift(); + const undecoratedDuration = formattedParts.map(x => x.s).join(separator); + switch (options.onTruncation) { + case "show_less_than_when_close_to_zero": { + if (formattedParts.length === 1 && formattedParts[0] && formattedParts[0].n === 0 && hasRemainder) { + return `${less_than}1${suffixes[formattedParts[0].units].singular}`; + } else { + return undecoratedDuration; + } + } + case undefined: { + return undecoratedDuration; + } + default: + throw new Error(`invalid truncation option: ${options.onTruncation as never}`); + } + } + /** Parses ISO-8601 duration string (e.g., "P1DT2H30M45.123S"). */ static fromISO8601(str: string): Maybe { // Supports: P[nD]T[nH][nM][nS] with optional decimals on any component diff --git a/core/tests/suites/time.ts b/core/tests/suites/time.ts index 903bfba..f20febc 100644 --- a/core/tests/suites/time.ts +++ b/core/tests/suites/time.ts @@ -199,6 +199,72 @@ const tests = group("time", [ expect.equals(result.remainder.asMilliseconds(), 0); }), ]), + test("toFormatted", () => { + // trims leading zeros, but not internal ones? + expect.equals(Duration.days(1).add(Duration.minutes(5)).toFormatted("short"), "1d 0h 5m 0s 0ms"); + // truncates correctly after minutes (no special behavior)? + expect.equals( + Duration.days(1) + .add(Duration.minutes(5)) + .add(Duration.seconds(40)) + .toFormatted("short", { truncateAfter: "minutes" }), + "1d 0h 5m", + ); + // truncates correctly after seconds (no special behavior)? + expect.equals( + Duration.days(1) + .add(Duration.minutes(5)) + .add(Duration.seconds(40)) + .add(Duration.milliseconds(500)) + .toFormatted("short", { truncateAfter: "seconds" }), + "1d 0h 5m 40s", + ); + // truncates correctly after minutes close to zero (show "less than")? + expect.equals( + Duration.seconds(20).toFormatted("short", { + truncateAfter: "minutes", + onTruncation: "show_less_than_when_close_to_zero", + }), + "< 1m", + ); + // truncates correctly after seconds close to zero (show "less than")? + expect.equals( + Duration.milliseconds(400).toFormatted("short", { + truncateAfter: "seconds", + onTruncation: "show_less_than_when_close_to_zero", + }), + "< 1s", + ); + // singular/plural for long units + expect.equals( + Duration.weeks(1) + .add(Duration.days(1)) + .add(Duration.hours(1)) + .add(Duration.minutes(1)) + .add(Duration.seconds(1)) + .add(Duration.milliseconds(1)) + .toFormatted("long"), + "1 week, 1 day, 1 hour, 1 minute, 1 second, 1 millisecond", + ); + expect.equals( + Duration.weeks(2) + .add(Duration.days(2)) + .add(Duration.hours(2)) + .add(Duration.minutes(2)) + .add(Duration.seconds(2)) + .add(Duration.milliseconds(2)) + .toFormatted("long"), + "2 weeks, 2 days, 2 hours, 2 minutes, 2 seconds, 2 milliseconds", + ); + // truncation behavior in long format + expect.equals( + Duration.seconds(5).toFormatted("long", { + truncateAfter: "minutes", + onTruncation: "show_less_than_when_close_to_zero", + }), + "less than 1 minute", + ); + }), ]), ]); From 44573675a6d0b224dd3a1a12e146a12b7057c674 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 10 Jun 2026 13:28:24 +0100 Subject: [PATCH 2/4] Remove trailing zeroes. --- core/src/time.ts | 154 +++++++++++++++----------------------- core/tests/suites/time.ts | 2 +- core/tsconfig.json | 4 +- 3 files changed, 62 insertions(+), 98 deletions(-) diff --git a/core/src/time.ts b/core/src/time.ts index a9dae07..d08ccf1 100644 --- a/core/src/time.ts +++ b/core/src/time.ts @@ -336,82 +336,21 @@ class Duration { return new Duration(Math.abs(this.millis)); } - /** - * Absolute value of duration broken into units (where the duration equals the sum of these units). - * To preserve a backwards-compatible API, an option for `largestUnits` is provided that defaults - * to "days". See also Duration#toPartsArray(). - */ parts(): { days: number; hours: number; minutes: number; seconds: number; milliseconds: number; - }; - parts(options: { largestUnits: "weeks" }): { - weeks: number; - days: number; - hours: number; - minutes: number; - seconds: number; - milliseconds: number; - }; - parts(options: { largestUnits: "days" | "weeks" } = { largestUnits: "days" }): { - weeks?: number; - days: number; - hours: number; - minutes: number; - seconds: number; - milliseconds: number; } { const d = new Duration(Math.abs(this.millis)); - switch (options.largestUnits) { - case "days": { - return { - days: Math.floor(d.asDays()), - hours: Math.floor(d.asHours()) % 24, - minutes: Math.floor(d.asMinutes()) % 60, - seconds: Math.floor(d.asSeconds()) % 60, - milliseconds: Math.floor(d.asMilliseconds() % 1_000), - }; - } - case "weeks": { - return { - weeks: Math.floor(d.asWeeks()), - days: Math.floor(d.asDays()) % 7, - hours: Math.floor(d.asHours()) % 24, - minutes: Math.floor(d.asMinutes()) % 60, - seconds: Math.floor(d.asSeconds()) % 60, - milliseconds: Math.floor(d.asMilliseconds() % 1_000), - }; - } - default: { - throw new Error(`invalid largest unit: ${options.largestUnits as never}`); - } - } - } - - /** - * Returns duration broken into units as an array, ordered from largest to smallest unit. - * See also Duration#parts. - */ - toPartsArray(): [ - [number, units: "weeks"], - [number, units: "days"], - [number, units: "hours"], - [number, units: "minutes"], - [number, units: "seconds"], - [number, units: "milliseconds"], - ] { - const { weeks, days, hours, minutes, seconds, milliseconds } = this.parts({ largestUnits: "weeks" }); - return [ - [weeks, "weeks"], - [days, "days"], - [hours, "hours"], - [minutes, "minutes"], - [seconds, "seconds"], - [milliseconds, "milliseconds"], - ]; + return { + days: Math.floor(d.asDays()), + hours: Math.floor(d.asHours()) % 24, + minutes: Math.floor(d.asMinutes()) % 60, + seconds: Math.floor(d.asSeconds()) % 60, + milliseconds: Math.floor(d.asMilliseconds() % 1_000), + }; } /** Formats duration as ISO-8601 string (e.g., "P1DT2H30M45.123S"). */ @@ -448,37 +387,62 @@ class Duration { truncateAfter?: "minutes" | "seconds"; onTruncation?: "show_less_than_when_close_to_zero"; } = {}, - ) { - const parts = this.toPartsArray(); + ): string { + if (this.millis < 0) { + return this.absolute().toFormatted(verbosity, options); + } + + const { days: _days, hours, minutes, seconds, milliseconds } = this.parts(); + const weeks = Math.floor(this.asWeeks()); + const days = _days % 7; const { suffixes, less_than, separator } = DURATION_FORMAT_CONFIG[verbosity]; - const formattedParts = parts.map(([n, units]) => ({ - n, - s: n.toString() + (n === 1 ? suffixes[units].singular : suffixes[units].plural), - units, - truncateAfter: options.truncateAfter === units, - })); - const truncateIdx = formattedParts.findIndex(x => x.truncateAfter); - // determine if there's a remainder after any truncation - const hasRemainder = truncateIdx > -1 && formattedParts.slice(truncateIdx + 1).some(x => x.n !== 0); - // perform truncation - if (truncateIdx > 0) formattedParts.splice(truncateIdx + 1); - // remove leading zeros (ensuring there's one unit left) - while (formattedParts.length > 1 && formattedParts[0]?.n === 0) formattedParts.shift(); - const undecoratedDuration = formattedParts.map(x => x.s).join(separator); - switch (options.onTruncation) { - case "show_less_than_when_close_to_zero": { - if (formattedParts.length === 1 && formattedParts[0] && formattedParts[0].n === 0 && hasRemainder) { - return `${less_than}1${suffixes[formattedParts[0].units].singular}`; - } else { - return undecoratedDuration; - } + + // Zero + if (this.millis === 0) { + return "0" + suffixes[options.truncateAfter ?? "milliseconds"].plural; + } + + // Close to zero + if (options.onTruncation === "show_less_than_when_close_to_zero") { + // Less than one minute + if (options.truncateAfter === "minutes" && this.asMinutes() < 1) { + return `${less_than}1${suffixes["minutes"].singular}`; } - case undefined: { - return undecoratedDuration; + + // Less than one second + if (options.truncateAfter === "seconds" && this.asSeconds() < 1) { + return `${less_than}1${suffixes["seconds"].singular}`; + } + + // Less than one millisecond + if (this.asMilliseconds() < 1) { + return `${less_than}1${suffixes["milliseconds"].singular}`; } - default: - throw new Error(`invalid truncation option: ${options.onTruncation as never}`); } + + const parts = ( + [ + [weeks, "weeks"], + [days, "days"], + [hours, "hours"], + [minutes, "minutes"], + [seconds, "seconds"], + [milliseconds, "milliseconds"], + ] as const + ).slice( + 0, + options.truncateAfter === "minutes" ? 4 + : options.truncateAfter === "seconds" ? 5 + : 6, + ); + + while (parts[0]?.[0] === 0) parts.shift(); // remove leading zeroes + while (parts[parts.length - 1]?.[0] === 0) parts.pop(); // remove trailing zeroes + + const pluralised = (amount: number, forms: { singular: string; plural: string }) => + amount.toString() + (amount == 1 ? forms.singular : forms.plural); + + return parts.map(([amount, unit]) => pluralised(amount, suffixes[unit])).join(separator); } /** Parses ISO-8601 duration string (e.g., "P1DT2H30M45.123S"). */ diff --git a/core/tests/suites/time.ts b/core/tests/suites/time.ts index f20febc..8c5b40f 100644 --- a/core/tests/suites/time.ts +++ b/core/tests/suites/time.ts @@ -201,7 +201,7 @@ const tests = group("time", [ ]), test("toFormatted", () => { // trims leading zeros, but not internal ones? - expect.equals(Duration.days(1).add(Duration.minutes(5)).toFormatted("short"), "1d 0h 5m 0s 0ms"); + expect.equals(Duration.days(1).add(Duration.minutes(5)).toFormatted("short"), "1d 0h 5m"); // truncates correctly after minutes (no special behavior)? expect.equals( Duration.days(1) diff --git a/core/tsconfig.json b/core/tsconfig.json index 3dee2c1..2a26cc1 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2023"], "declaration": true, "baseUrl": "./src", "strict": true, From 1d236fcc758bc6c41ab225d279e8a750ff61158d Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 10 Jun 2026 13:28:34 +0100 Subject: [PATCH 3/4] Split toFormatted tests --- core/tests/suites/time.ts | 141 ++++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 66 deletions(-) diff --git a/core/tests/suites/time.ts b/core/tests/suites/time.ts index 8c5b40f..7777221 100644 --- a/core/tests/suites/time.ts +++ b/core/tests/suites/time.ts @@ -199,72 +199,81 @@ const tests = group("time", [ expect.equals(result.remainder.asMilliseconds(), 0); }), ]), - test("toFormatted", () => { - // trims leading zeros, but not internal ones? - expect.equals(Duration.days(1).add(Duration.minutes(5)).toFormatted("short"), "1d 0h 5m"); - // truncates correctly after minutes (no special behavior)? - expect.equals( - Duration.days(1) - .add(Duration.minutes(5)) - .add(Duration.seconds(40)) - .toFormatted("short", { truncateAfter: "minutes" }), - "1d 0h 5m", - ); - // truncates correctly after seconds (no special behavior)? - expect.equals( - Duration.days(1) - .add(Duration.minutes(5)) - .add(Duration.seconds(40)) - .add(Duration.milliseconds(500)) - .toFormatted("short", { truncateAfter: "seconds" }), - "1d 0h 5m 40s", - ); - // truncates correctly after minutes close to zero (show "less than")? - expect.equals( - Duration.seconds(20).toFormatted("short", { - truncateAfter: "minutes", - onTruncation: "show_less_than_when_close_to_zero", - }), - "< 1m", - ); - // truncates correctly after seconds close to zero (show "less than")? - expect.equals( - Duration.milliseconds(400).toFormatted("short", { - truncateAfter: "seconds", - onTruncation: "show_less_than_when_close_to_zero", - }), - "< 1s", - ); - // singular/plural for long units - expect.equals( - Duration.weeks(1) - .add(Duration.days(1)) - .add(Duration.hours(1)) - .add(Duration.minutes(1)) - .add(Duration.seconds(1)) - .add(Duration.milliseconds(1)) - .toFormatted("long"), - "1 week, 1 day, 1 hour, 1 minute, 1 second, 1 millisecond", - ); - expect.equals( - Duration.weeks(2) - .add(Duration.days(2)) - .add(Duration.hours(2)) - .add(Duration.minutes(2)) - .add(Duration.seconds(2)) - .add(Duration.milliseconds(2)) - .toFormatted("long"), - "2 weeks, 2 days, 2 hours, 2 minutes, 2 seconds, 2 milliseconds", - ); - // truncation behavior in long format - expect.equals( - Duration.seconds(5).toFormatted("long", { - truncateAfter: "minutes", - onTruncation: "show_less_than_when_close_to_zero", - }), - "less than 1 minute", - ); - }), + group("toFormatted", [ + test("trims leading zeros, but not internal ones", () => { + expect.equals(Duration.days(1).add(Duration.minutes(5)).toFormatted("short"), "1d 0h 5m"); + }), + test("truncates correctly after minutes (no special behavior)", () => { + expect.equals( + Duration.days(1) + .add(Duration.minutes(5)) + .add(Duration.seconds(40)) + .toFormatted("short", { truncateAfter: "minutes" }), + "1d 0h 5m", + ); + }), + test("truncates correctly after seconds (no special behavior)", () => { + expect.equals( + Duration.days(1) + .add(Duration.minutes(5)) + .add(Duration.seconds(40)) + .add(Duration.milliseconds(500)) + .toFormatted("short", { truncateAfter: "seconds" }), + "1d 0h 5m 40s", + ); + }), + test('truncates correctly after minutes close to zero (show "less than")', () => { + expect.equals( + Duration.seconds(20).toFormatted("short", { + truncateAfter: "minutes", + onTruncation: "show_less_than_when_close_to_zero", + }), + "< 1m", + ); + }), + test('truncates correctly after seconds close to zero (show "less than")', () => { + expect.equals( + Duration.milliseconds(400).toFormatted("short", { + truncateAfter: "seconds", + onTruncation: "show_less_than_when_close_to_zero", + }), + "< 1s", + ); + }), + test("singular for long units", () => { + expect.equals( + Duration.weeks(1) + .add(Duration.days(1)) + .add(Duration.hours(1)) + .add(Duration.minutes(1)) + .add(Duration.seconds(1)) + .add(Duration.milliseconds(1)) + .toFormatted("long"), + "1 week, 1 day, 1 hour, 1 minute, 1 second, 1 millisecond", + ); + }), + test("plural for long units", () => { + expect.equals( + Duration.weeks(2) + .add(Duration.days(2)) + .add(Duration.hours(2)) + .add(Duration.minutes(2)) + .add(Duration.seconds(2)) + .add(Duration.milliseconds(2)) + .toFormatted("long"), + "2 weeks, 2 days, 2 hours, 2 minutes, 2 seconds, 2 milliseconds", + ); + }), + test("truncation behavior in long format", () => { + expect.equals( + Duration.seconds(5).toFormatted("long", { + truncateAfter: "minutes", + onTruncation: "show_less_than_when_close_to_zero", + }), + "less than 1 minute", + ); + }), + ]), ]), ]); From 074c586e992ccdb275ce64477d729c31e0cd180d Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 10 Jun 2026 13:30:14 +0100 Subject: [PATCH 4/4] Use when calculating weeks --- core/src/time.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/time.ts b/core/src/time.ts index d08ccf1..4a1266d 100644 --- a/core/src/time.ts +++ b/core/src/time.ts @@ -257,7 +257,7 @@ class Duration { } static weeks(n: number): Duration { - return Duration.hours(n * 24 * 7); + return Duration.days(n * 7); } asMilliseconds(): number {