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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ coverage/
# Temporary files
*.tmp
.cache/

# for folks who use Wakatime for time-in-editor insights
.wakatime-project
112 changes: 111 additions & 1 deletion core/src/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -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;
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -298,7 +336,6 @@ class Duration {
return new Duration(Math.abs(this.millis));
}

/** Parts as absolute numbers. */
parts(): {
days: number;
hours: number;
Expand Down Expand Up @@ -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<Duration> {
// Supports: P[nD]T[nH][nM][nS] with optional decimals on any component
Expand Down
75 changes: 75 additions & 0 deletions core/tests/suites/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}),
]),
]),
]);

Expand Down
4 changes: 2 additions & 2 deletions core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"module": "ESNext",
"lib": ["ES2022"],
"lib": ["ES2023"],
"declaration": true,
"baseUrl": "./src",
"strict": true,
Expand Down
Loading