diff --git a/build-scripts/babel-plugins/custom-polyfill-plugin.js b/build-scripts/babel-plugins/custom-polyfill-plugin.js index b121eb86c94e..b16c15104750 100644 --- a/build-scripts/babel-plugins/custom-polyfill-plugin.js +++ b/build-scripts/babel-plugins/custom-polyfill-plugin.js @@ -147,6 +147,7 @@ const polyfillMap = { ...Object.fromEntries( [ "DateTimeFormat", + "DurationFormat", "DisplayNames", "ListFormat", "NumberFormat", diff --git a/build-scripts/gulp/locale-data.js b/build-scripts/gulp/locale-data.js index 4cf91711d495..14ce9bc06a3c 100755 --- a/build-scripts/gulp/locale-data.js +++ b/build-scripts/gulp/locale-data.js @@ -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", diff --git a/package.json b/package.json index 00a39f50e342..920479bacce8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/datetime/duration.ts b/src/common/datetime/duration.ts index 36fef1902a00..de3b64a671ce 100644 --- a/src/common/datetime/duration.ts +++ b/src/common/datetime/duration.ts @@ -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"); + } +}; diff --git a/src/common/datetime/format_duration.ts b/src/common/datetime/format_duration.ts index 9279a8b0ce47..973d7ce9273f 100644 --- a/src/common/datetime/format_duration.ts +++ b/src/common/datetime/format_duration.ts @@ -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 ) => { diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 4423c5395433..d7e00f1e34a7 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -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"; @@ -32,7 +29,6 @@ export const computeStateDisplay = ( const entity = entities?.[stateObj.entity_id] as | EntityRegistryDisplayEntry | undefined; - return computeStateDisplayFromEntityAttributes( localize, locale, @@ -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 } diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts index 2651f71246e9..68a6ce30841b 100644 --- a/src/data/automation_i18n.ts +++ b/src/data/automation_i18n.ts @@ -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 { @@ -42,7 +42,7 @@ const describeDuration = ( } else if (typeof forTime === "string") { duration = forTime; } else { - duration = formatDuration(locale, forTime); + duration = formatNumericDuration(locale, forTime); } return duration; }; diff --git a/src/data/entity_attributes.ts b/src/data/entity_attributes.ts index ad80f4365d9a..75dde0e7762f 100644 --- a/src/data/entity_attributes.ts +++ b/src/data/entity_attributes.ts @@ -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 = [ @@ -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 })!, }, }; diff --git a/src/data/script_i18n.ts b/src/data/script_i18n.ts index c14fd7f13719..81cd488236b8 100644 --- a/src/data/script_i18n.ts +++ b/src/data/script_i18n.ts @@ -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"; @@ -277,7 +277,7 @@ const tryDescribeAction = ( duration = hass.localize( `${actionTranslationBaseKey}.delay.description.duration_string`, { - string: formatDuration(hass.locale, config.delay), + string: formatNumericDuration(hass.locale, config.delay), } ); } else { diff --git a/src/resources/polyfills/intl-polyfill.ts b/src/resources/polyfills/intl-polyfill.ts index 7832f5939833..97026fdbaf06 100644 --- a/src/resources/polyfills/intl-polyfill.ts +++ b/src/resources/polyfills/intl-polyfill.ts @@ -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, @@ -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")); } diff --git a/test/common/datetime/duration.test.ts b/test/common/datetime/duration.test.ts index 99cdfd9bed22..c0dc1b3c81d8 100644 --- a/test/common/datetime/duration.test.ts +++ b/test/common/datetime/duration.test.ts @@ -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"); }); }); diff --git a/yarn.lock b/yarn.lock index c5814bef2a4b..a973b9522c59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1691,6 +1691,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-durationformat@npm:0.6.4": + version: 0.6.4 + resolution: "@formatjs/intl-durationformat@npm:0.6.4" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.2.4" + "@formatjs/intl-localematcher": "npm:0.5.8" + tslib: "npm:2" + checksum: 10/aa7c045e94322f0a5723584e79dd1395b3c08e5d1d79432748ae98c0b0c1727059bd1aa5c04090df916ff1aea768336184a2d09ecfd2ece2fb090c472bbd8250 + languageName: node + linkType: hard + "@formatjs/intl-enumerator@npm:1.8.4": version: 1.8.4 resolution: "@formatjs/intl-enumerator@npm:1.8.4" @@ -9487,6 +9498,7 @@ __metadata: "@egjs/hammerjs": "npm:2.0.17" "@formatjs/intl-datetimeformat": "npm:6.16.5" "@formatjs/intl-displaynames": "npm:6.8.5" + "@formatjs/intl-durationformat": "npm:0.6.4" "@formatjs/intl-getcanonicallocales": "npm:2.5.3" "@formatjs/intl-listformat": "npm:7.7.5" "@formatjs/intl-locale": "npm:4.2.5"