Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 97192fe316 Regenerate statistics chart data when the formatters change
statistics-chart resolves its series names once and caches them in
_chartData, and it rebuilt only when the fetched statistics changed. A
rename therefore left the legend showing the old name.

The formatters are replaced as a set whenever the registries change, so
comparing formatEntityName identity is enough to catch it. This covers
the statistics graph card and the more-info history dialog; the energy
dashboard cards cache their series the same way and are handled
separately.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 11:35:31 +01:00
Bram KragtenandClaude Opus 5 00f2c71231 Keep the entity name shape out of the core chunk
recorder.ts sits in the core entry chunk, so importing DEFAULT_ENTITY_NAME
from compute_entity_name_display dragged the registry-aware formatters and
their dependencies in with it and blew the core bundle budget by 4.1%.

The constant and the name types have no runtime dependencies, so they move
to their own module; compute_entity_name_display re-exports them so nothing
else has to change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 15:41:43 +02:00
Bram KragtenandClaude Opus 5 66820826fa Resolve statistic labels with structured entity names
getStatisticLabel resolved names through computeStateName, so every
statistic-shaped label — the energy dashboard cards, the sankey cards,
hui-statistic-card, chart series names and the energy configuration
dialogs — showed the flat friendly_name attribute while the rest of the
UI composed names from the device, area and floor.

It now asks hass.formatEntityName for the default composition, which
also makes the entity branch of computeEnergyLabel redundant.

The metadata fallback stays: external statistics have no entity to
resolve a name against.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 15:21:43 +02:00
6 changed files with 155 additions and 39 deletions
@@ -19,37 +19,16 @@ import {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
export type {
EntityNameItem,
EntityNameOptions,
EntityNameType,
} from "./entity_name_config";
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
+34
View File
@@ -0,0 +1,34 @@
/**
* Shape of a composed entity name, kept free of runtime dependencies so that
* modules in the core entry chunk can name an entity without pulling in the
* registry-aware formatters from `compute_entity_name_display`.
*/
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
+19 -2
View File
@@ -125,7 +125,23 @@ export class StatisticsChart extends LitElement {
private _yAxisFractionDigits = 1;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
return (
changedProps.size > 1 ||
!changedProps.has("hass") ||
this._entityNamesChanged(changedProps)
);
}
// Series names are resolved once and cached in _chartData, so a hass update
// that only replaces the formatters has to regenerate them. The formatters are
// swapped as a set whenever the registries change, which is what renames a
// series.
private _entityNamesChanged(changedProps: PropertyValues): boolean {
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
return !!oldHass && oldHass.formatEntityName !== this.hass.formatEntityName;
}
public willUpdate(changedProps: PropertyValues) {
@@ -135,7 +151,8 @@ export class StatisticsChart extends LitElement {
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats") ||
changedProps.has("names")
changedProps.has("names") ||
this._entityNamesChanged(changedProps)
) {
this._generateData();
}
-7
View File
@@ -21,7 +21,6 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -328,12 +327,6 @@ export const computeEnergyLabel = (
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../common/entity/compute_state_name";
import { DEFAULT_ENTITY_NAME } from "../common/entity/entity_name_config";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import { firstWeekday } from "../common/datetime/first_weekday";
@@ -348,8 +348,9 @@ export const getStatisticLabel = (
): string => {
const entity = hass.states[statisticsId];
if (entity) {
return computeStateName(entity);
return hass.formatEntityName(entity, DEFAULT_ENTITY_NAME);
}
// External statistics have no entity to resolve a name against.
return statisticsMetaData?.name || statisticsId;
};
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { StatisticsMetaData } from "../../src/data/recorder";
import { getStatisticLabel, StatisticMeanType } from "../../src/data/recorder";
import {
mockArea,
mockDevice,
mockEntity,
} from "../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../fixtures/hass";
const metadata = (
statistic_id: string,
name: string | null
): StatisticsMetaData => ({
statistic_id,
name,
source: "recorder",
statistics_unit_of_measurement: "kWh",
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
// The friendly name deliberately differs from the composed name, so the
// assertions fail if the label is taken from the attribute again.
const hassWithRegistry = () =>
createMockHass(
{
"sensor.dishwasher_energy": createMockEntityState(
"sensor.dishwasher_energy",
"12",
{ friendly_name: "Smart plug 3 energy" }
),
},
{
entities: {
"sensor.dishwasher_energy": mockEntity({
entity_id: "sensor.dishwasher_energy",
device_id: "device_1",
name: "Energy",
}),
},
devices: {
device_1: mockDevice({
id: "device_1",
name: "Dishwasher",
area_id: "kitchen",
}),
},
areas: { kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }) },
}
);
describe("getStatisticLabel", () => {
it("composes the name from the registry rather than friendly_name", () => {
expect(
getStatisticLabel(
hassWithRegistry(),
"sensor.dishwasher_energy",
metadata("sensor.dishwasher_energy", "Recorder name")
)
).toBe("Dishwasher Energy");
});
// External statistics have no entity, so there is no registry context to
// compose a name from and the recorder metadata is all we have.
it("uses the metadata name when there is no entity", () => {
expect(
getStatisticLabel(
createMockHass(),
"energy:solar_production",
metadata("energy:solar_production", "Solar production")
)
).toBe("Solar production");
});
it("falls back to the statistic id when metadata has no name", () => {
expect(
getStatisticLabel(
createMockHass(),
"energy:solar_production",
metadata("energy:solar_production", null)
)
).toBe("energy:solar_production");
});
it("falls back to the statistic id when there is no metadata", () => {
expect(
getStatisticLabel(createMockHass(), "energy:solar_production", undefined)
).toBe("energy:solar_production");
});
});