diff --git a/pyproject.toml b/pyproject.toml index 4738435b2a..f64ffeaaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251203.2" +version = "20251203.3" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 1d47313e56..b920767823 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -659,7 +659,7 @@ export class HaAssistChat extends LitElement { --markdown-table-border-color: var(--divider-color); --markdown-code-background-color: var(--primary-background-color); --markdown-code-text-color: var(--primary-text-color); - --markdown-list-indent: 1rem; + --markdown-list-indent: 1.15em; &:not(:has(ha-markdown-element)) { min-height: 1lh; min-width: 1lh; diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 6f955cb7e2..85117b91d2 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -344,7 +344,10 @@ export class HaGenericPicker extends LitElement { wa-popover::part(body) { width: max(var(--body-width), 250px); - max-width: max(var(--body-width), 250px); + max-width: var( + --ha-generic-picker-max-width, + max(var(--body-width), 250px) + ); max-height: 500px; height: 70vh; overflow: hidden; diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 04a18ec52b..c0f435ab34 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -74,9 +74,6 @@ export class HaMarkdown extends LitElement { background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); max-width: 100%; - height: auto; - width: auto; - transition: height 0.2s ease-in-out; } p:first-child > img:first-child { vertical-align: top; @@ -84,8 +81,7 @@ export class HaMarkdown extends LitElement { p:first-child > img:last-child { vertical-align: top; } - :host > ul, - :host > ol { + ha-markdown-element > :is(ol, ul) { padding-inline-start: var(--markdown-list-indent, revert); } li { @@ -136,6 +132,18 @@ export class HaMarkdown extends LitElement { border-bottom: none; margin: var(--ha-space-4) 0; } + table[role="presentation"] { + --markdown-table-border-collapse: separate; + --markdown-table-border-width: attr(border, 0); + --markdown-table-padding-inline: 0; + --markdown-table-padding-block: 0; + th { + vertical-align: attr(align, center); + } + td { + vertical-align: attr(align, left); + } + } table { border-collapse: var(--markdown-table-border-collapse, collapse); } @@ -143,14 +151,15 @@ export class HaMarkdown extends LitElement { overflow: auto; } th { - text-align: start; + text-align: var(--markdown-table-text-align, start); } td, th { border-width: var(--markdown-table-border-width, 1px); border-style: var(--markdown-table-border-style, solid); border-color: var(--markdown-table-border-color, var(--divider-color)); - padding: 0.25em 0.5em; + padding-inline: var(--markdown-table-padding-inline, 0.5em); + padding-block: var(--markdown-table-padding-block, 0.25em); } blockquote { border-left: 4px solid var(--divider-color); diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0d77a72695..28894efe76 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -952,10 +952,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { let hasFloor = false; let rtl = false; let showEntityId = false; - if (type === "area" || type === "floor") { - item.id = item[type]?.[`${type}_id`]; - rtl = computeRTL(this.hass); hasFloor = type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; diff --git a/src/components/ha-toast.ts b/src/components/ha-toast.ts index 4f6be82b24..16ff711073 100644 --- a/src/components/ha-toast.ts +++ b/src/components/ha-toast.ts @@ -13,6 +13,7 @@ export class HaToast extends Snackbar { } .mdc-snackbar { + z-index: 10; margin: 8px; right: calc(8px + var(--safe-area-inset-right)); bottom: calc(8px + var(--safe-area-inset-bottom)); diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts index f52c6d0f79..8ba75e3987 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts @@ -321,40 +321,39 @@ export class HaAutomationAddSearch extends LitElement { > ` : nothing} - ${item.icon - ? html`` - : item.icon_path - ? html`` - : type === "entity" && (item as EntityComboBoxItem).stateObj - ? html` - - ` - : type === "device" && (item as DevicePickerItem).domain + ${(item as AutomationItemComboBoxItem).renderedIcon + ? html`
+ ${(item as AutomationItemComboBoxItem).renderedIcon} +
` + : item.icon + ? html`` + : item.icon_path || type === "area" + ? html`` + : type === "entity" && (item as EntityComboBoxItem).stateObj ? html` - + > ` - : type === "floor" - ? html`` - : type === "area" - ? html`` + .hass=${this.hass} + .domain=${(item as DevicePickerItem).domain!} + brand-fallback + > + ` + : type === "floor" + ? html`` : nothing} ${item.primary} ${item.secondary @@ -784,7 +783,7 @@ export class HaAutomationAddSearch extends LitElement { id: key, primary: name, secondary: description, - iconPath, + icon_path: iconPath, renderedIcon: icon, type, search_labels: [key, name, description], diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index c62e9142b9..09202895ca 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -631,6 +631,7 @@ class HaPanelHistory extends LitElement { :host([virtualize]) { height: 100%; + --ha-generic-picker-max-width: 400px; } .progress-wrapper { diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 365731df37..1f8d5eb2f4 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -303,6 +303,9 @@ export class HaPanelLogbook extends LitElement { return [ haStyle, css` + :host { + --ha-generic-picker-max-width: 400px; + } ha-logbook { height: calc( 100vh - diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 0636e4cee3..d49ac755ae 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) { }); } +function getDatapointX(datapoint: NonNullable[0]) { + const item = + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : { value: datapoint }; + return Number(item.value?.[0]); +} + export function fillLineGaps(datasets: LineSeriesOption[]) { const buckets = Array.from( new Set( datasets .map((dataset) => - dataset.data!.map((datapoint) => Number(datapoint![0])) + dataset.data!.map((datapoint) => getDatapointX(datapoint)) ) .flat() ) ).sort((a, b) => a - b); - buckets.forEach((bucket, index) => { - for (let i = datasets.length - 1; i >= 0; i--) { - const dataPoint = datasets[i].data![index]; + + datasets.forEach((dataset) => { + const dataMap = new Map(); + dataset.data!.forEach((datapoint) => { const item: LineDataItemOption = - dataPoint && typeof dataPoint === "object" && "value" in dataPoint - ? dataPoint - : ({ value: dataPoint } as LineDataItemOption); - const x = item.value?.[0]; - if (x === undefined) { - continue; + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : ({ value: datapoint } as LineDataItemOption); + const x = getDatapointX(datapoint); + if (!Number.isNaN(x)) { + dataMap.set(x, item); } - if (Number(x) !== bucket) { - datasets[i].data?.splice(index, 0, [bucket, 0]); - } - } + }); + + dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]); }); + return datasets; } diff --git a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts index fdc61f029c..940abaec9e 100644 --- a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts @@ -41,7 +41,9 @@ const computeHeadingCard = ( action: "navigate", navigation_path, } - : undefined, + : { + action: "none", + }, }) satisfies HeadingCardConfig; @customElement("home-area-view-strategy") @@ -182,7 +184,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.scenes"), "mdi:palette", - "/config/scene/dashboard" + hass.user?.is_admin ? "/config/scene/dashboard" : undefined ), ...scenes.map(computeTileCard), ], @@ -285,12 +287,13 @@ export class HomeAreaViewStrategy extends ReactiveElement { { type: "heading", heading: heading, - tap_action: device - ? { - action: "navigate", - navigation_path: `/config/devices/device/${device.id}`, - } - : undefined, + tap_action: + hass.user?.is_admin && device + ? { + action: "navigate", + navigation_path: `/config/devices/device/${device.id}`, + } + : { action: "none" }, badges: [ ...batteryEntities.slice(0, 1).map((e) => ({ entity: e, @@ -334,7 +337,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.automations"), "mdi:robot", - "/config/automation/dashboard" + hass.user?.is_admin ? "/config/automation/dashboard" : undefined ), ...automations.map(computeTileCard), ], diff --git a/src/resources/markdown-worker.ts b/src/resources/markdown-worker.ts index dfade11123..414457a480 100644 --- a/src/resources/markdown-worker.ts +++ b/src/resources/markdown-worker.ts @@ -19,6 +19,7 @@ const renderMarkdown = async ( if (!whiteListNormal) { whiteListNormal = { ...getDefaultWhiteList(), + table: [...(getDefaultWhiteList().table ?? []), "role"], input: ["type", "disabled", "checked"], "ha-icon": ["icon"], "ha-svg-icon": ["path"], diff --git a/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts new file mode 100644 index 0000000000..0f1a7dc53c --- /dev/null +++ b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts @@ -0,0 +1,199 @@ +import { assert, describe, it } from "vitest"; +import type { LineSeriesOption } from "echarts/charts"; + +import { fillLineGaps } from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options"; + +// Helper to get x value from either [x,y] or {value: [x,y]} format +function getX(item: any): number { + return item?.value?.[0] ?? item?.[0]; +} + +// Helper to get y value from either [x,y] or {value: [x,y]} format +function getY(item: any): number { + return item?.value?.[1] ?? item?.[1]; +} + +describe("fillLineGaps", () => { + it("fills gaps in datasets with missing timestamps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [3000, 30], + ], + }, + { + type: "line", + data: [ + [1000, 100], + [2000, 200], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should have gap at 2000 filled with 0 + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be unchanged + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles unsorted data from multiple sources", () => { + // This is the bug we're fixing: when multiple power sources are combined, + // the data may not be in chronological order + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + [2000, 20], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // Data should be sorted by timestamp + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("handles multiple datasets with unsorted data", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + ], + }, + { + type: "line", + data: [ + [2000, 200], + [1000, 100], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should be sorted and have gap at 2000 filled + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be sorted + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles data with object format (LineDataItemOption)", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [{ value: [3000, 30] }, { value: [1000, 10] }], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 2); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 3000); + assert.equal(getY(result[0].data![1]), 30); + }); + + it("returns empty array for empty datasets", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.deepEqual(result[0].data, []); + }); + + it("handles already sorted data with no gaps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [2000, 20], + [3000, 30], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("preserves original data item properties", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + { value: [2000, 20], itemStyle: { color: "red" } }, + { value: [1000, 10], itemStyle: { color: "blue" } }, + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First item should be the one with timestamp 1000 + const firstItem = result[0].data![0] as any; + assert.equal(getX(firstItem), 1000); + assert.equal(firstItem.itemStyle.color, "blue"); + + // Second item should be the one with timestamp 2000 + const secondItem = result[0].data![1] as any; + assert.equal(getX(secondItem), 2000); + assert.equal(secondItem.itemStyle.color, "red"); + }); +});