mirror of
https://github.com/home-assistant/frontend.git
synced 2025-12-20 02:38:53 +00:00
Merge branch 'rc'
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -321,12 +321,16 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
: nothing}
|
||||
${item.icon
|
||||
${(item as AutomationItemComboBoxItem).renderedIcon
|
||||
? html`<div slot="start">
|
||||
${(item as AutomationItemComboBoxItem).renderedIcon}
|
||||
</div>`
|
||||
: item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: item.icon_path
|
||||
: item.icon_path || type === "area"
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
.path=${item.icon_path || mdiTextureBox}
|
||||
></ha-svg-icon>`
|
||||
: type === "entity" && (item as EntityComboBoxItem).stateObj
|
||||
? html`
|
||||
@@ -350,11 +354,6 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
slot="start"
|
||||
.floor=${(item as FloorComboBoxItem).floor!}
|
||||
></ha-floor-icon>`
|
||||
: type === "area"
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path || mdiTextureBox}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${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],
|
||||
|
||||
@@ -631,6 +631,7 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
:host([virtualize]) {
|
||||
height: 100%;
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -303,6 +303,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
ha-logbook {
|
||||
height: calc(
|
||||
100vh -
|
||||
|
||||
@@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[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<number, LineDataItemOption>();
|
||||
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;
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, [bucket, 0]);
|
||||
}
|
||||
datapoint && typeof datapoint === "object" && "value" in datapoint
|
||||
? datapoint
|
||||
: ({ value: datapoint } as LineDataItemOption);
|
||||
const x = getDatapointX(datapoint);
|
||||
if (!Number.isNaN(x)) {
|
||||
dataMap.set(x, item);
|
||||
}
|
||||
});
|
||||
|
||||
dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]);
|
||||
});
|
||||
|
||||
return datasets;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
tap_action:
|
||||
hass.user?.is_admin && device
|
||||
? {
|
||||
action: "navigate",
|
||||
navigation_path: `/config/devices/device/${device.id}`,
|
||||
}
|
||||
: undefined,
|
||||
: { 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),
|
||||
],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user