diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 80792dca79..5a65756246 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -9,7 +9,11 @@ import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration"; import { formatDate } from "../datetime/format_date"; import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; -import { formatNumber, isNumericFromAttributes } from "../number/format_number"; +import { + formatNumber, + getNumberFormatOptions, + isNumericFromAttributes, +} from "../number/format_number"; import { blankBeforePercent } from "../translations/blank_before_percent"; import { LocalizeFunc } from "../translations/localize"; import { computeDomain } from "./compute_domain"; @@ -70,7 +74,11 @@ export const computeStateDisplayFromEntityAttributes = ( : attributes.unit_of_measurement === "%" ? blankBeforePercent(locale) + "%" : ` ${attributes.unit_of_measurement}`; - return `${formatNumber(state, locale)}${unit}`; + return `${formatNumber( + state, + locale, + getNumberFormatOptions({ state, attributes } as HassEntity) + )}${unit}`; } const domain = computeDomain(entityId); @@ -143,7 +151,12 @@ export const computeStateDisplayFromEntityAttributes = ( domain === "number" || domain === "input_number" ) { - return formatNumber(state, locale); + // Format as an integer if the value and step are integers + return formatNumber( + state, + locale, + getNumberFormatOptions({ state, attributes } as HassEntity) + ); } // state of button is a timestamp diff --git a/src/common/number/format_number.ts b/src/common/number/format_number.ts index 5b9c1bb1b4..e3f78f1b79 100644 --- a/src/common/number/format_number.ts +++ b/src/common/number/format_number.ts @@ -1,4 +1,7 @@ -import { HassEntity } from "home-assistant-js-websocket"; +import { + HassEntity, + HassEntityAttributeBase, +} from "home-assistant-js-websocket"; import { FrontendLocaleData, NumberFormat } from "../../data/translation"; import { round } from "./round"; @@ -9,9 +12,9 @@ import { round } from "./round"; export const isNumericState = (stateObj: HassEntity): boolean => isNumericFromAttributes(stateObj.attributes); -export const isNumericFromAttributes = (attributes: { - [key: string]: any; -}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class; +export const isNumericFromAttributes = ( + attributes: HassEntityAttributeBase +): boolean => !!attributes.unit_of_measurement || !!attributes.state_class; export const numberFormatToLocale = ( localeOptions: FrontendLocaleData @@ -81,12 +84,29 @@ export const formatNumber = ( }`; }; +/** + * Checks if the current entity state should be formatted as an integer based on the `state` and `step` attribute and returns the appropriate `Intl.NumberFormatOptions` object with `maximumFractionDigits` set + * @param entityState The state object of the entity + * @returns An `Intl.NumberFormatOptions` object with `maximumFractionDigits` set to 0, or `undefined` + */ +export const getNumberFormatOptions = ( + entityState: HassEntity +): Intl.NumberFormatOptions | undefined => { + if ( + Number.isInteger(Number(entityState.attributes?.step)) && + Number.isInteger(Number(entityState.state)) + ) { + return { maximumFractionDigits: 0 }; + } + return undefined; +}; + /** * Generates default options for Intl.NumberFormat * @param num The number to be formatted * @param options The Intl.NumberFormatOptions that should be included in the returned options */ -const getDefaultFormatOptions = ( +export const getDefaultFormatOptions = ( num: string | number, options?: Intl.NumberFormatOptions ): Intl.NumberFormatOptions => { @@ -102,7 +122,8 @@ const getDefaultFormatOptions = ( // Keep decimal trailing zeros if they are present in a string numeric value if ( !options || - (!options.minimumFractionDigits && !options.maximumFractionDigits) + (options.minimumFractionDigits === undefined && + options.maximumFractionDigits === undefined) ) { const digits = num.indexOf(".") > -1 ? num.split(".")[1].length : 0; defaultOptions.minimumFractionDigits = digits; diff --git a/src/components/entity/ha-state-label-badge.ts b/src/components/entity/ha-state-label-badge.ts index 919ad6de55..5b05e0d7d7 100644 --- a/src/components/entity/ha-state-label-badge.ts +++ b/src/components/entity/ha-state-label-badge.ts @@ -16,6 +16,7 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; import { formatNumber, + getNumberFormatOptions, isNumericState, } from "../../common/number/format_number"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; @@ -149,7 +150,11 @@ export class HaStateLabelBadge extends LitElement { entityState.state === UNAVAILABLE ? "—" : isNumericState(entityState) - ? formatNumber(entityState.state, this.hass!.locale) + ? formatNumber( + entityState.state, + this.hass!.locale, + getNumberFormatOptions(entityState) + ) : computeStateDisplay( this.hass!.localize, entityState, diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts index 5defde0d93..46e2f18b78 100644 --- a/src/panels/lovelace/cards/hui-entity-card.ts +++ b/src/panels/lovelace/cards/hui-entity-card.ts @@ -17,6 +17,7 @@ import { computeStateName } from "../../../common/entity/compute_state_name"; import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { formatNumber, + getNumberFormatOptions, isNumericState, } from "../../../common/number/format_number"; import { iconColorCSS } from "../../../common/style/icon_color_css"; @@ -147,7 +148,11 @@ export class HuiEntityCard extends LitElement implements LovelaceCard { ) : this.hass.localize("state.default.unknown") : isNumericState(stateObj) || this._config.unit - ? formatNumber(stateObj.state, this.hass.locale) + ? formatNumber( + stateObj.state, + this.hass.locale, + getNumberFormatOptions(stateObj) + ) : computeStateDisplay( this.hass.localize, stateObj, diff --git a/test/common/string/format_number.ts b/test/common/string/format_number.ts index f762f22305..e8f402e3a4 100644 --- a/test/common/string/format_number.ts +++ b/test/common/string/format_number.ts @@ -1,6 +1,11 @@ import { assert } from "chai"; +import { HassEntity } from "home-assistant-js-websocket"; -import { formatNumber } from "../../../src/common/number/format_number"; +import { + formatNumber, + getDefaultFormatOptions, + getNumberFormatOptions, +} from "../../../src/common/number/format_number"; import { FrontendLocaleData, NumberFormat, @@ -63,4 +68,80 @@ describe("formatNumber", () => { "1,234.50" ); }); + + it("Sets only the maximumFractionDigits format option when none are provided for a number value", () => { + assert.deepEqual(getDefaultFormatOptions(1234.5), { + maximumFractionDigits: 2, + }); + }); + + it("Sets minimumFractionDigits and maximumFractionDigits to '2' when none are provided for a string numeric value with two decimal places", () => { + assert.deepEqual(getDefaultFormatOptions("1234.50"), { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }); + + it("Merges default format options (minimumFractionDigits and maximumFractionDigits) and non-default format options for a string numeric value with two decimal places", () => { + assert.deepEqual(getDefaultFormatOptions("1234.50", { currency: "USD" }), { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: "USD", + }); + }); + + it("Sets maximumFractionDigits when that is the only format option provided", () => { + assert.deepEqual( + getDefaultFormatOptions("1234.50", { maximumFractionDigits: 0 }), + { + maximumFractionDigits: 0, + } + ); + }); + + it("Sets maximumFractionDigits to '2' and minimumFractionDigits to the provided value when only minimumFractionDigits is provided", () => { + assert.deepEqual( + getDefaultFormatOptions("1234.50", { minimumFractionDigits: 1 }), + { + minimumFractionDigits: 1, + maximumFractionDigits: 2, + } + ); + }); + + it("Sets maximumFractionDigits to '0' when the state value and step are integers", () => { + assert.deepEqual( + getNumberFormatOptions({ + state: "3.0", + attributes: { step: 1 }, + } as unknown as HassEntity), + { + maximumFractionDigits: 0, + } + ); + }); + + it("Does not set any Intl.NumberFormatOptions when the step is not an integer", () => { + assert.strictEqual( + getNumberFormatOptions({ + state: "3.0", + attributes: { step: 0.5 }, + } as unknown as HassEntity), + undefined + ); + }); + + it("Does not set any Intl.NumberFormatOptions when the state value is not an integer", () => { + assert.strictEqual( + getNumberFormatOptions({ state: "3.5" } as unknown as HassEntity), + undefined + ); + }); + + it("Does not set any Intl.NumberFormatOptions when there is no step attribute", () => { + assert.strictEqual( + getNumberFormatOptions({ state: "3.0" } as unknown as HassEntity), + undefined + ); + }); });