Skip to content

Commit

Permalink
Use explicit duration format for state formatting (#23017)
Browse files Browse the repository at this point in the history
  • Loading branch information
piitaya authored Nov 27, 2024
1 parent dd7987e commit a532b44
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 67 deletions.
1 change: 1 addition & 0 deletions build-scripts/babel-plugins/custom-polyfill-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const polyfillMap = {
...Object.fromEntries(
[
"DateTimeFormat",
"DurationFormat",
"DisplayNames",
"ListFormat",
"NumberFormat",
Expand Down
1 change: 1 addition & 0 deletions build-scripts/gulp/locale-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const outDir = join(paths.build_dir, "locale-data");

const INTL_POLYFILLS = {
"intl-datetimeformat": "DateTimeFormat",
"intl-durationFormat": "DurationFormat",
"intl-displaynames": "DisplayNames",
"intl-listformat": "ListFormat",
"intl-numberformat": "NumberFormat",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.16.5",
"@formatjs/intl-displaynames": "6.8.5",
"@formatjs/intl-durationformat": "0.6.4",
"@formatjs/intl-getcanonicallocales": "2.5.3",
"@formatjs/intl-listformat": "7.7.5",
"@formatjs/intl-locale": "4.2.5",
Expand Down
126 changes: 108 additions & 18 deletions src/common/datetime/duration.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,109 @@
import millisecondsToDuration from "./milliseconds_to_duration";

const DAY_IN_MILLISECONDS = 86400000;
const HOUR_IN_MILLISECONDS = 3600000;
const MINUTE_IN_MILLISECONDS = 60000;
const SECOND_IN_MILLISECONDS = 1000;

export const UNIT_TO_MILLISECOND_CONVERT = {
ms: 1,
s: SECOND_IN_MILLISECONDS,
min: MINUTE_IN_MILLISECONDS,
h: HOUR_IN_MILLISECONDS,
d: DAY_IN_MILLISECONDS,
};
import { DurationFormat } from "@formatjs/intl-durationformat";
import type { DurationInput } from "@formatjs/intl-durationformat/src/types";
import memoizeOne from "memoize-one";
import type { FrontendLocaleData } from "../../data/translation";
import { round } from "../number/round";

export const DURATION_UNITS = ["ms", "s", "min", "h", "d"] as const;

type DurationUnit = (typeof DURATION_UNITS)[number];

const formatDurationDayMem = memoizeOne(
(locale: FrontendLocaleData) =>
new DurationFormat(locale.language, {
style: "narrow",
daysDisplay: "always",
})
);

const formatDurationHourMem = memoizeOne(
(locale: FrontendLocaleData) =>
new DurationFormat(locale.language, {
style: "narrow",
hoursDisplay: "always",
})
);

export const formatDuration = (duration: string, units: string): string =>
millisecondsToDuration(
parseFloat(duration) * UNIT_TO_MILLISECOND_CONVERT[units]
) || "0";
const formatDurationMinuteMem = memoizeOne(
(locale: FrontendLocaleData) =>
new DurationFormat(locale.language, {
style: "narrow",
minutesDisplay: "always",
})
);

const formatDurationSecondMem = memoizeOne(
(locale: FrontendLocaleData) =>
new DurationFormat(locale.language, {
style: "narrow",
secondsDisplay: "always",
})
);

const formatDurationMillisecondMem = memoizeOne(
(locale: FrontendLocaleData) =>
new DurationFormat(locale.language, {
style: "narrow",
millisecondsDisplay: "always",
})
);

export const formatDuration = (
duration: string,
unit: DurationUnit,
precision: number | undefined,
locale: FrontendLocaleData
): string => {
const value =
precision !== undefined
? round(parseFloat(duration), precision)
: parseFloat(duration);

switch (unit) {
case "d": {
const days = Math.floor(value);
const hours = Math.floor((value - days) * 24);
const input: DurationInput = {
days,
hours,
};
return formatDurationDayMem(locale).format(input);
}
case "h": {
const hours = Math.floor(value);
const minutes = Math.floor((value - hours) * 60);
const input: DurationInput = {
hours,
minutes,
};
return formatDurationHourMem(locale).format(input);
}
case "min": {
const minutes = Math.floor(value);
const seconds = Math.floor((value - minutes) * 60);
const input: DurationInput = {
minutes,
seconds,
};
return formatDurationMinuteMem(locale).format(input);
}
case "s": {
const seconds = Math.floor(value);
const milliseconds = Math.floor((value - seconds) * 1000);
const input: DurationInput = {
seconds,
milliseconds,
};
return formatDurationSecondMem(locale).format(input);
}
case "ms": {
const milliseconds = Math.floor(value);
const input: DurationInput = {
milliseconds,
};
return formatDurationMillisecondMem(locale).format(input);
}
default:
throw new Error("Invalid duration unit");
}
};
2 changes: 1 addition & 1 deletion src/common/datetime/format_duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { formatListWithAnds } from "../string/format-list";

const leftPad = (num: number) => (num < 10 ? `0${num}` : num);

export const formatDuration = (
export const formatNumericDuration = (
locale: FrontendLocaleData,
duration: HaDurationData
) => {
Expand Down
15 changes: 8 additions & 7 deletions src/common/entity/compute_state_display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { HomeAssistant } from "../../types";
import {
UNIT_TO_MILLISECOND_CONVERT,
formatDuration,
} from "../datetime/duration";
import { DURATION_UNITS, formatDuration } from "../datetime/duration";
import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time";
import { formatTime } from "../datetime/format_time";
Expand All @@ -32,7 +29,6 @@ export const computeStateDisplay = (
const entity = entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;

return computeStateDisplayFromEntityAttributes(
localize,
locale,
Expand Down Expand Up @@ -72,10 +68,15 @@ export const computeStateDisplayFromEntityAttributes = (
if (
attributes.device_class === "duration" &&
attributes.unit_of_measurement &&
UNIT_TO_MILLISECOND_CONVERT[attributes.unit_of_measurement]
DURATION_UNITS.includes(attributes.unit_of_measurement)
) {
try {
return formatDuration(state, attributes.unit_of_measurement);
return formatDuration(
state,
attributes.unit_of_measurement,
entity?.display_precision,
locale
);
} catch (_err) {
// fallback to default
}
Expand Down
4 changes: 2 additions & 2 deletions src/data/automation_i18n.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HassConfig } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import {
formatDuration,
formatNumericDuration,
formatDurationLong,
} from "../common/datetime/format_duration";
import {
Expand Down Expand Up @@ -42,7 +42,7 @@ const describeDuration = (
} else if (typeof forTime === "string") {
duration = forTime;
} else {
duration = formatDuration(locale, forTime);
duration = formatNumericDuration(locale, forTime);
}
return duration;
};
Expand Down
5 changes: 3 additions & 2 deletions src/data/entity_attributes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatDuration } from "../common/datetime/duration";
import { formatNumericDuration } from "../common/datetime/format_duration";
import type { FrontendLocaleData } from "./translation";

export const STATE_ATTRIBUTES = [
Expand Down Expand Up @@ -99,6 +99,7 @@ export const DOMAIN_ATTRIBUTES_FORMATERS: Record<
},
media_player: {
volume_level: (value) => Math.round(value * 100).toString(),
media_duration: (value) => formatDuration(value.toString(), "s"),
media_duration: (value, locale) =>
formatNumericDuration(locale, { seconds: value })!,
},
};
4 changes: 2 additions & 2 deletions src/data/script_i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ensureArray } from "../common/array/ensure-array";
import { formatDuration } from "../common/datetime/format_duration";
import { formatNumericDuration } from "../common/datetime/format_duration";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { computeStateName } from "../common/entity/compute_state_name";
import { formatListWithAnds } from "../common/string/format-list";
Expand Down Expand Up @@ -277,7 +277,7 @@ const tryDescribeAction = <T extends ActionType>(
duration = hass.localize(
`${actionTranslationBaseKey}.delay.description.duration_string`,
{
string: formatDuration(hass.locale, config.delay),
string: formatNumericDuration(hass.locale, config.delay),
}
);
} else {
Expand Down
5 changes: 5 additions & 0 deletions src/resources/polyfills/intl-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { shouldPolyfill as shouldPolyfillLocale } from "@formatjs/intl-locale/sh
import { shouldPolyfill as shouldPolyfillNumberFormat } from "@formatjs/intl-numberformat/should-polyfill";
import { shouldPolyfill as shouldPolyfillPluralRules } from "@formatjs/intl-pluralrules/should-polyfill";
import { shouldPolyfill as shouldPolyfillRelativeTimeFormat } from "@formatjs/intl-relativetimeformat/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";

import { getLocalLanguage } from "../../util/common-translation";
import {
polyfillLocaleData,
Expand All @@ -28,6 +30,9 @@ const polyfillIntl = async () => {
)
);
}
if (shouldPolyfillDurationFormat()) {
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
}
if (shouldPolyfillDisplayNames(locale)) {
polyfills.push(import("@formatjs/intl-displaynames/polyfill-force"));
}
Expand Down
113 changes: 78 additions & 35 deletions test/common/datetime/duration.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,90 @@
import { assert, describe, it } from "vitest";

import { formatDuration } from "../../../src/common/datetime/duration";
import type { FrontendLocaleData } from "../../../src/data/translation";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../src/data/translation";

const LOCALE: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.am_pm,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
};

describe("formatDuration", () => {
it("works", () => {
assert.strictEqual(formatDuration("0", "ms"), "0");
assert.strictEqual(formatDuration("1", "ms"), "0.001");
assert.strictEqual(formatDuration("10", "ms"), "0.010");
assert.strictEqual(formatDuration("100", "ms"), "0.100");
assert.strictEqual(formatDuration("1000", "ms"), "1");
assert.strictEqual(formatDuration("1001", "ms"), "1.001");
assert.strictEqual(formatDuration("65000", "ms"), "1:05");
assert.strictEqual(formatDuration("3665000", "ms"), "1:01:05");
assert.strictEqual(formatDuration("39665050", "ms"), "11:01:05");
assert.strictEqual(formatDuration("932093000", "ms"), "258:54:53");
assert.strictEqual(formatDuration("0", "ms", undefined, LOCALE), "0ms");
assert.strictEqual(formatDuration("1", "ms", undefined, LOCALE), "1ms");
assert.strictEqual(formatDuration("10", "ms", undefined, LOCALE), "10ms");
assert.strictEqual(formatDuration("100", "ms", undefined, LOCALE), "100ms");
assert.strictEqual(
formatDuration("1000", "ms", undefined, LOCALE),
"1,000ms"
);
assert.strictEqual(
formatDuration("1001", "ms", undefined, LOCALE),
"1,001ms"
);
assert.strictEqual(
formatDuration("65000", "ms", undefined, LOCALE),
"65,000ms"
);

assert.strictEqual(formatDuration("0", "s"), "0");
assert.strictEqual(formatDuration("1", "s"), "1");
assert.strictEqual(formatDuration("1.1", "s"), "1.100");
assert.strictEqual(formatDuration("65", "s"), "1:05");
assert.strictEqual(formatDuration("3665", "s"), "1:01:05");
assert.strictEqual(formatDuration("39665", "s"), "11:01:05");
assert.strictEqual(formatDuration("932093", "s"), "258:54:53");
assert.strictEqual(
formatDuration("0.5", "s", undefined, LOCALE),
"0s 500ms"
);
assert.strictEqual(formatDuration("1", "s", undefined, LOCALE), "1s");
assert.strictEqual(
formatDuration("1.1", "s", undefined, LOCALE),
"1s 100ms"
);
assert.strictEqual(formatDuration("65", "s", undefined, LOCALE), "65s");

assert.strictEqual(formatDuration("0", "min"), "0");
assert.strictEqual(formatDuration("65", "min"), "1:05:00");
assert.strictEqual(formatDuration("3665", "min"), "61:05:00");
assert.strictEqual(formatDuration("39665", "min"), "661:05:00");
assert.strictEqual(formatDuration("932093", "min"), "15534:53:00");
assert.strictEqual(formatDuration("12.4", "min"), "12:24");
assert.strictEqual(
formatDuration("0.25", "min", undefined, LOCALE),
"0m 15s"
);
assert.strictEqual(
formatDuration("0.5", "min", undefined, LOCALE),
"0m 30s"
);
assert.strictEqual(formatDuration("1", "min", undefined, LOCALE), "1m");
assert.strictEqual(formatDuration("20", "min", undefined, LOCALE), "20m");
assert.strictEqual(
formatDuration("95.5", "min", undefined, LOCALE),
"95m 30s"
);

assert.strictEqual(formatDuration("0", "h"), "0");
assert.strictEqual(formatDuration("65", "h"), "65:00:00");
assert.strictEqual(formatDuration("3665", "h"), "3665:00:00");
assert.strictEqual(formatDuration("39665", "h"), "39665:00:00");
assert.strictEqual(formatDuration("932093", "h"), "932093:00:00");
assert.strictEqual(formatDuration("24.3", "h"), "24:18:00");
assert.strictEqual(formatDuration("24.32423", "h"), "24:19:27");
assert.strictEqual(
formatDuration("0.25", "h", undefined, LOCALE),
"0h 15m"
);
assert.strictEqual(formatDuration("0.5", "h", undefined, LOCALE), "0h 30m");
assert.strictEqual(formatDuration("1", "h", undefined, LOCALE), "1h");
assert.strictEqual(formatDuration("20", "h", undefined, LOCALE), "20h");
assert.strictEqual(
formatDuration("95.5", "h", undefined, LOCALE),
"95h 30m"
);

assert.strictEqual(formatDuration("0", "d"), "0");
assert.strictEqual(formatDuration("65", "d"), "1560:00:00");
assert.strictEqual(formatDuration("3665", "d"), "87960:00:00");
assert.strictEqual(formatDuration("39665", "d"), "951960:00:00");
assert.strictEqual(formatDuration("932093", "d"), "22370232:00:00");
assert.strictEqual(formatDuration("0", "d", undefined, LOCALE), "0d");
assert.strictEqual(formatDuration("0.4", "d", undefined, LOCALE), "0d 9h");
assert.strictEqual(formatDuration("1", "d", undefined, LOCALE), "1d");
assert.strictEqual(formatDuration("20", "d", undefined, LOCALE), "20d");
assert.strictEqual(
formatDuration("95.5", "d", undefined, LOCALE),
"95d 12h"
);
assert.strictEqual(formatDuration("95.75", "d", 0, LOCALE), "96d");
assert.strictEqual(formatDuration("95.75", "d", 2, LOCALE), "95d 18h");
});
});
Loading

0 comments on commit a532b44

Please sign in to comment.