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..4a1266d 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.days(n * 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,7 +336,6 @@ class Duration { return new Duration(Math.abs(this.millis)); } - /** Parts as absolute numbers. */ parts(): { days: number; hours: number; @@ -335,6 +372,79 @@ 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"; + } = {}, + ): 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]; + + // 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}`; + } + + // 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}`; + } + } + + 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"). */ 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..7777221 100644 --- a/core/tests/suites/time.ts +++ b/core/tests/suites/time.ts @@ -199,6 +199,81 @@ const tests = group("time", [ expect.equals(result.remainder.asMilliseconds(), 0); }), ]), + 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", + ); + }), + ]), ]), ]); 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,