Move execution time formatting into its own util (#1386)
Refactors and rewrites the humanize time functions used on the dashboard, and swaps out these new functions in a couple of places. Examples of these functions' behaviours can be found in the tests for them. <img width="375" alt="Screenshot 2023-11-16 at 8 07 14 PM" src="https://github.com/webrecorder/browsertrix-cloud/assets/5727389/775b3a49-1061-4002-8c34-961777423542"> <img width="267" alt="Screenshot 2023-11-16 at 8 07 45 PM" src="https://github.com/webrecorder/browsertrix-cloud/assets/5727389/1d22aec0-4b88-4a9a-b1d7-f6612d287769"> <img width="224" alt="Screenshot 2023-11-16 at 8 21 13 PM" src="https://github.com/webrecorder/browsertrix-cloud/assets/5727389/7d895938-ea02-4ffa-9f82-8526725f36c5"> Also fixes inconsistent tooltip text alignment on the dashboard :)
This commit is contained in:
parent
0638e5dad8
commit
d64def00c2
@ -4,7 +4,6 @@ import { when } from "lit/directives/when.js";
|
|||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
import { classMap } from "lit/directives/class-map.js";
|
import { classMap } from "lit/directives/class-map.js";
|
||||||
import { msg, localized, str } from "@lit/localize";
|
import { msg, localized, str } from "@lit/localize";
|
||||||
import humanizeDuration from "pretty-ms";
|
|
||||||
|
|
||||||
import type { PageChangeEvent } from "../../components/pagination";
|
import type { PageChangeEvent } from "../../components/pagination";
|
||||||
import { RelativeDuration } from "../../components/relative-duration";
|
import { RelativeDuration } from "../../components/relative-duration";
|
||||||
@ -14,6 +13,7 @@ import { isActive } from "../../utils/crawler";
|
|||||||
import { CopyButton } from "../../components/copy-button";
|
import { CopyButton } from "../../components/copy-button";
|
||||||
import type { Crawl, Seed } from "./types";
|
import type { Crawl, Seed } from "./types";
|
||||||
import { APIPaginatedList } from "../../types/api";
|
import { APIPaginatedList } from "../../types/api";
|
||||||
|
import { humanizeExecutionSeconds } from "../../utils/executionTimeFormatter";
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
"overview",
|
"overview",
|
||||||
@ -644,7 +644,7 @@ export class CrawlDetail extends LiteElement {
|
|||||||
<btrix-desc-list-item label=${msg("Execution Time")}>
|
<btrix-desc-list-item label=${msg("Execution Time")}>
|
||||||
${this.crawl!.finished
|
${this.crawl!.finished
|
||||||
? html`<span
|
? html`<span
|
||||||
>${humanizeDuration(
|
>${humanizeExecutionSeconds(
|
||||||
this.crawl!.crawlExecSeconds * 1000
|
this.crawl!.crawlExecSeconds * 1000
|
||||||
)}</span
|
)}</span
|
||||||
>`
|
>`
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import { when } from "lit/directives/when.js";
|
|||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
import { msg, localized, str } from "@lit/localize";
|
import { msg, localized, str } from "@lit/localize";
|
||||||
import type { SlSelectEvent } from "@shoelace-style/shoelace";
|
import type { SlSelectEvent } from "@shoelace-style/shoelace";
|
||||||
import humanizeMilliseconds from "pretty-ms";
|
|
||||||
|
|
||||||
import LiteElement, { html } from "../../utils/LiteElement";
|
import LiteElement, { html } from "../../utils/LiteElement";
|
||||||
import type { AuthState } from "../../utils/AuthService";
|
import type { AuthState } from "../../utils/AuthService";
|
||||||
import type { OrgData } from "../../utils/orgs";
|
import type { OrgData } from "../../utils/orgs";
|
||||||
import type { SelectNewDialogEvent } from "./index";
|
import type { SelectNewDialogEvent } from "./index";
|
||||||
import { getLocale } from "../../utils/localization";
|
import {
|
||||||
|
humanizeExecutionSeconds,
|
||||||
|
humanizeSeconds,
|
||||||
|
} from "../../utils/executionTimeFormatter";
|
||||||
|
|
||||||
type Metrics = {
|
type Metrics = {
|
||||||
storageUsedBytes: number;
|
storageUsedBytes: number;
|
||||||
@ -66,30 +68,6 @@ export class Dashboard extends LiteElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private humanizeExecutionSeconds = (seconds: number) => {
|
|
||||||
const minutes = Math.ceil(seconds / 60);
|
|
||||||
|
|
||||||
const locale = getLocale();
|
|
||||||
const compactFormatter = new Intl.NumberFormat(locale, {
|
|
||||||
notation: "compact",
|
|
||||||
style: "unit",
|
|
||||||
unit: "minute",
|
|
||||||
unitDisplay: "long",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fullFormatter = new Intl.NumberFormat(locale, {
|
|
||||||
style: "unit",
|
|
||||||
unit: "minute",
|
|
||||||
unitDisplay: "long",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return html`<span title="${fullFormatter.format(minutes)}">
|
|
||||||
${compactFormatter.format(minutes)}</span
|
|
||||||
>
|
|
||||||
(${humanizeMilliseconds(seconds * 1000)})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const hasQuota = Boolean(this.metrics?.storageQuotaBytes);
|
const hasQuota = Boolean(this.metrics?.storageQuotaBytes);
|
||||||
const quotaReached =
|
const quotaReached =
|
||||||
@ -347,7 +325,7 @@ export class Dashboard extends LiteElement {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<div slot="available" class="flex-1">
|
<div slot="available" class="flex-1">
|
||||||
<sl-tooltip>
|
<sl-tooltip class="text-center">
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<div>${msg("Available")}</div>
|
<div>${msg("Available")}</div>
|
||||||
<div class="text-xs opacity-80">
|
<div class="text-xs opacity-80">
|
||||||
@ -415,7 +393,7 @@ export class Dashboard extends LiteElement {
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div>${label}</div>
|
<div>${label}</div>
|
||||||
<div class="text-xs opacity-80">
|
<div class="text-xs opacity-80">
|
||||||
${humanizeMilliseconds(value * 1000)} |
|
${humanizeExecutionSeconds(value)} |
|
||||||
${this.renderPercentage(value / quotaSeconds)}
|
${this.renderPercentage(value / quotaSeconds)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -438,9 +416,7 @@ export class Dashboard extends LiteElement {
|
|||||||
hasQuota
|
hasQuota
|
||||||
? html`
|
? html`
|
||||||
<span class="inline-flex items-center">
|
<span class="inline-flex items-center">
|
||||||
${this.humanizeExecutionSeconds(
|
${humanizeExecutionSeconds(quotaSeconds - usageSeconds)}
|
||||||
quotaSeconds - usageSeconds
|
|
||||||
)}
|
|
||||||
${msg("Available")}
|
${msg("Available")}
|
||||||
</span>
|
</span>
|
||||||
`
|
`
|
||||||
@ -464,14 +440,11 @@ export class Dashboard extends LiteElement {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<div slot="available" class="flex-1">
|
<div slot="available" class="flex-1">
|
||||||
<sl-tooltip>
|
<sl-tooltip class="text-center">
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<div>${msg("Monthly Execution Time Available")}</div>
|
<div>${msg("Monthly Execution Time Available")}</div>
|
||||||
<div class="text-xs opacity-80">
|
<div class="text-xs opacity-80">
|
||||||
${this.humanizeExecutionSeconds(
|
${humanizeExecutionSeconds(quotaSeconds - usageSeconds)} |
|
||||||
quotaSeconds - usageSeconds
|
|
||||||
)}
|
|
||||||
|
|
|
||||||
${this.renderPercentage(
|
${this.renderPercentage(
|
||||||
(quotaSeconds - usageSeconds) / quotaSeconds
|
(quotaSeconds - usageSeconds) / quotaSeconds
|
||||||
)}
|
)}
|
||||||
@ -481,10 +454,10 @@ export class Dashboard extends LiteElement {
|
|||||||
</sl-tooltip>
|
</sl-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<span slot="valueLabel">
|
<span slot="valueLabel">
|
||||||
${this.humanizeExecutionSeconds(usageSeconds)}
|
${humanizeExecutionSeconds(usageSeconds, "short")}
|
||||||
</span>
|
</span>
|
||||||
<span slot="maxLabel">
|
<span slot="maxLabel">
|
||||||
${this.humanizeExecutionSeconds(quotaSeconds)}
|
${humanizeExecutionSeconds(quotaSeconds, "short")}
|
||||||
</span>
|
</span>
|
||||||
</btrix-meter>
|
</btrix-meter>
|
||||||
</div>
|
</div>
|
||||||
@ -603,8 +576,8 @@ export class Dashboard extends LiteElement {
|
|||||||
>
|
>
|
||||||
</sl-format-date>
|
</sl-format-date>
|
||||||
`,
|
`,
|
||||||
value ? this.humanizeExecutionSeconds(value) : "--",
|
value ? humanizeExecutionSeconds(value) : "--",
|
||||||
humanizeMilliseconds(crawlTime * 1000 || 0),
|
humanizeSeconds(crawlTime || 0),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
76
frontend/src/utils/executionTimeFormatter.test.ts
Normal file
76
frontend/src/utils/executionTimeFormatter.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
humanizeSeconds,
|
||||||
|
humanizeExecutionSeconds,
|
||||||
|
} from "./executionTimeFormatter";
|
||||||
|
import { expect, fixture } from "@open-wc/testing";
|
||||||
|
|
||||||
|
describe("formatHours", () => {
|
||||||
|
it("returns a time in hours and minutes when given a time over an hour", () => {
|
||||||
|
expect(humanizeSeconds(12_345, "en-US")).to.equal("3h 26m");
|
||||||
|
});
|
||||||
|
it("returns 1m when given a time under a minute", () => {
|
||||||
|
expect(humanizeSeconds(24, "en-US")).to.equal("1m");
|
||||||
|
});
|
||||||
|
it("returns 0m and seconds when given a time under a minute with seconds on", () => {
|
||||||
|
expect(humanizeSeconds(24, "en-US", true)).to.equal("0m 24s");
|
||||||
|
});
|
||||||
|
it("returns minutes when given a time under an hour", () => {
|
||||||
|
expect(humanizeSeconds(1_234, "en-US")).to.equal("21m");
|
||||||
|
});
|
||||||
|
it("returns just hours when given a time exactly in hours", () => {
|
||||||
|
expect(humanizeSeconds(3_600, "en-US")).to.equal("1h");
|
||||||
|
expect(humanizeSeconds(44_442_000, "en-US")).to.equal("12,345h");
|
||||||
|
});
|
||||||
|
it("handles different locales correctly", () => {
|
||||||
|
expect(humanizeSeconds(44_442_000_000, "en-IN")).to.equal("1,23,45,000h");
|
||||||
|
expect(humanizeSeconds(44_442_000_000, "pt-BR")).to.equal("12.345.000 h");
|
||||||
|
expect(humanizeSeconds(44_442_000_000, "de-DE")).to.equal(
|
||||||
|
"12.345.000 Std."
|
||||||
|
);
|
||||||
|
expect(humanizeSeconds(44_442_000_000, "ar-EG")).to.equal("١٢٬٣٤٥٬٠٠٠ س");
|
||||||
|
});
|
||||||
|
it("formats zero time as expected", () => {
|
||||||
|
expect(humanizeSeconds(0, "en-US")).to.equal("0m");
|
||||||
|
});
|
||||||
|
it("formats zero time as expected", () => {
|
||||||
|
expect(humanizeSeconds(0, "en-US", true)).to.equal("0s");
|
||||||
|
});
|
||||||
|
it("formats negative time as expected", () => {
|
||||||
|
expect(() => humanizeSeconds(-100, "en-US")).to.throw(
|
||||||
|
"humanizeSeconds in unimplemented for negative times"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("humanizeExecutionSeconds", () => {
|
||||||
|
it("formats a given time in billable minutes", async () => {
|
||||||
|
const parentNode = document.createElement("div");
|
||||||
|
const el = await fixture(humanizeExecutionSeconds(1_234_567_890), {
|
||||||
|
parentNode,
|
||||||
|
});
|
||||||
|
expect(el.getAttribute("title")).to.equal("20,576,132 minutes");
|
||||||
|
expect(el.textContent?.trim()).to.equal("21M minutes");
|
||||||
|
expect(parentNode.innerText).to.equal("21M minutes\u00a0(342,935h 32m)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a short version when set", async () => {
|
||||||
|
const parentNode = document.createElement("div");
|
||||||
|
const el = await fixture(humanizeExecutionSeconds(1_234_567_890, "short"), {
|
||||||
|
parentNode,
|
||||||
|
});
|
||||||
|
expect(el.getAttribute("title")).to.equal(
|
||||||
|
"20,576,132 minutes\u00a0(342,935h 32m)"
|
||||||
|
);
|
||||||
|
expect(el.textContent?.trim()).to.equal("21M minutes");
|
||||||
|
expect(parentNode.innerText).to.equal("21M minutes");
|
||||||
|
});
|
||||||
|
it("skips the details when given a time less than an hour that is exactly in minutes", async () => {
|
||||||
|
const parentNode = document.createElement("div");
|
||||||
|
const el = await fixture(humanizeExecutionSeconds(3_540), {
|
||||||
|
parentNode,
|
||||||
|
});
|
||||||
|
expect(el.getAttribute("title")).to.equal("59 minutes");
|
||||||
|
expect(el.textContent?.trim()).to.equal("59 minutes");
|
||||||
|
expect(parentNode.innerText).to.equal("59 minutes");
|
||||||
|
});
|
||||||
|
});
|
||||||
109
frontend/src/utils/executionTimeFormatter.ts
Normal file
109
frontend/src/utils/executionTimeFormatter.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { html, nothing } from "lit";
|
||||||
|
import { getLocale } from "./localization";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns either `nothing`, or hours-minutes-seconds wrapped in parens.
|
||||||
|
* Biases towards minutes:
|
||||||
|
* - When the time is exactly on an hour boundary, just shows hours
|
||||||
|
* - e.g. `3h`
|
||||||
|
* - When the time isn't on an hour boundary but is on a minute broundary, just shows hours (if applicable) and minutes
|
||||||
|
* - e.g. `3h 2m` or `32m`
|
||||||
|
* - When the time is less than a minute, shows minutes and seconds
|
||||||
|
* - e.g. `0m 43s`
|
||||||
|
*/
|
||||||
|
export function humanizeSeconds(
|
||||||
|
seconds: number,
|
||||||
|
locale?: string,
|
||||||
|
displaySeconds = false
|
||||||
|
) {
|
||||||
|
if (seconds < 0) {
|
||||||
|
throw new Error("humanizeSeconds in unimplemented for negative times");
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
seconds -= hours * 3600;
|
||||||
|
// If displaying seconds, round minutes down, otherwise round up
|
||||||
|
const minutes = displaySeconds
|
||||||
|
? Math.floor(seconds / 60)
|
||||||
|
: Math.ceil(seconds / 60);
|
||||||
|
seconds -= minutes * 60;
|
||||||
|
|
||||||
|
const hourFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit: "hour",
|
||||||
|
unitDisplay: "narrow",
|
||||||
|
});
|
||||||
|
|
||||||
|
const minuteFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit: "minute",
|
||||||
|
unitDisplay: "narrow",
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit: "second",
|
||||||
|
unitDisplay: "narrow",
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
hours !== 0 && hourFormatter.format(hours),
|
||||||
|
(minutes !== 0 || seconds !== 0 || (!displaySeconds && hours === 0)) &&
|
||||||
|
minuteFormatter.format(minutes),
|
||||||
|
displaySeconds &&
|
||||||
|
(seconds !== 0 || (hours === 0 && minutes === 0)) &&
|
||||||
|
secondFormatter.format(seconds),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats execution seconds, either just as minutes (when `style` is `"short"`), or as minutes and hours-minutes-seconds (when `style` is undefined or `"full"`)
|
||||||
|
* @example humanizeExecutionSeconds(1_234_567_890)
|
||||||
|
* // <span title="20,576,132 minutes">21M minutes</span> (342,935h 31m 30s)
|
||||||
|
*
|
||||||
|
* @example humanizeExecutionSeconds(1_234_567_890, "short")
|
||||||
|
* // <span title="20,576,132 minutes (342,935h 31m 30s)">21M minutes</span>
|
||||||
|
*/
|
||||||
|
export const humanizeExecutionSeconds = (
|
||||||
|
seconds: number,
|
||||||
|
style: "short" | "full" = "full",
|
||||||
|
displaySeconds = false
|
||||||
|
) => {
|
||||||
|
const locale = getLocale();
|
||||||
|
const minutes = Math.ceil(seconds / 60);
|
||||||
|
|
||||||
|
const compactMinuteFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
notation: "compact",
|
||||||
|
style: "unit",
|
||||||
|
unit: "minute",
|
||||||
|
unitDisplay: "long",
|
||||||
|
});
|
||||||
|
|
||||||
|
const longMinuteFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit: "minute",
|
||||||
|
unitDisplay: "long",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = humanizeSeconds(seconds, locale, displaySeconds);
|
||||||
|
|
||||||
|
// if the time is less than an hour and lines up exactly on the minute, don't render the details.
|
||||||
|
const formattedDetails =
|
||||||
|
minutes === Math.floor(seconds / 60) && seconds < 3600
|
||||||
|
? nothing
|
||||||
|
: `\u00a0(${details})`;
|
||||||
|
|
||||||
|
switch (style) {
|
||||||
|
case "full":
|
||||||
|
return html`<span title="${longMinuteFormatter.format(minutes)}">
|
||||||
|
${compactMinuteFormatter.format(minutes)}</span
|
||||||
|
>${formattedDetails}`;
|
||||||
|
case "short":
|
||||||
|
return html`<span
|
||||||
|
title="${longMinuteFormatter.format(minutes)}${formattedDetails}"
|
||||||
|
>${compactMinuteFormatter.format(minutes)}</span
|
||||||
|
>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user