mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 14:33:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1ac2161f |
@@ -118,8 +118,7 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
|
||||
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
|
||||
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
## When To Add Tests
|
||||
|
||||
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
|
||||
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
|
||||
- Do not try to cover every scenario, especially for behaviour that changes often.
|
||||
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
@@ -123,8 +123,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Array }) public data: DataTableRowData[] = [];
|
||||
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@@ -167,9 +165,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _filteredData?: DataTableRowData[];
|
||||
|
||||
// Row count of the data that _filteredData was computed from
|
||||
@state() private _filteredDataSourceLength = 0;
|
||||
|
||||
@state() private _headerHeight = 0;
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
@@ -528,9 +523,7 @@ export class HaDataTable extends LitElement {
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
this.loading ||
|
||||
!this._filteredData ||
|
||||
(this.data.length && !this._filteredDataSourceLength)
|
||||
!this._filteredData
|
||||
? this._i18n?.localize?.("ui.common.loading") ||
|
||||
"Loading"
|
||||
: this.data.length
|
||||
@@ -728,11 +721,10 @@ export class HaDataTable extends LitElement {
|
||||
!this._lastUpdate ||
|
||||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
|
||||
|
||||
const sourceData = this.data;
|
||||
let filteredData = sourceData;
|
||||
let filteredData = this.data;
|
||||
if (this._filter) {
|
||||
filteredData = await this._memFilterData(
|
||||
sourceData,
|
||||
this.data,
|
||||
this._sortColumns,
|
||||
this._filter.trim()
|
||||
);
|
||||
@@ -768,13 +760,8 @@ export class HaDataTable extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime < this._lastUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastUpdate = startTime;
|
||||
this._filteredData = data;
|
||||
this._filteredDataSourceLength = sourceData.length;
|
||||
}
|
||||
|
||||
private _groupData = memoizeOne(
|
||||
|
||||
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
|
||||
(response) => response.json()
|
||||
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
|
||||
* (24px by default), including inherited size overrides.
|
||||
*/
|
||||
@customElement("ha-skeleton-icon")
|
||||
export class HaSkeletonIcon extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: none;
|
||||
width: var(--mdc-icon-size, 24px);
|
||||
height: var(--mdc-icon-size, 24px);
|
||||
min-height: 0;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-icon": HaSkeletonIcon;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for a line of text, matching the tile secondary text skeleton.
|
||||
* Its height follows the surrounding font size. Set `width` in CSS to fit the
|
||||
* expected text.
|
||||
*
|
||||
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
|
||||
*/
|
||||
@customElement("ha-skeleton-text")
|
||||
export class HaSkeletonText extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
width: var(--ha-skeleton-text-width, 140px);
|
||||
max-width: 100%;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-text": HaSkeletonText;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Placeholder shown while content loads. Sized by its container unless a
|
||||
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
|
||||
*
|
||||
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
|
||||
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
|
||||
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
|
||||
*/
|
||||
@customElement("ha-skeleton")
|
||||
export class HaSkeleton extends WaSkeleton {
|
||||
override effect: WaSkeleton["effect"] = "pulse";
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
WaSkeleton.styles,
|
||||
css`
|
||||
:host {
|
||||
--color: var(
|
||||
--ha-skeleton-color,
|
||||
var(--ha-color-fill-neutral-normal-resting)
|
||||
);
|
||||
--sheen-color: var(
|
||||
--ha-skeleton-sheen-color,
|
||||
var(--ha-color-fill-neutral-loud-resting)
|
||||
);
|
||||
--wa-border-radius-pill: var(
|
||||
--ha-skeleton-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:host {
|
||||
--color: GrayText;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host([effect="pulse"]) .indicator,
|
||||
:host([effect="sheen"]) .indicator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton": HaSkeleton;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
|
||||
${
|
||||
this.secondaryLoading
|
||||
? html`<div class="secondary">
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
|
||||
</div>`
|
||||
: html`<slot name="secondary" class="secondary">
|
||||
<span>${this.secondary}</span>
|
||||
@@ -150,6 +150,14 @@ export class HaTileInfo extends LitElement {
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
max-width: 100%;
|
||||
height: var(--tile-info-secondary-font-size);
|
||||
--wa-border-radius-pill: var(--ha-border-radius-sm);
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "../../../../components/skeleton/ha-skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton-text";
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -113,14 +112,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
${
|
||||
!createBackupTexts
|
||||
? html`<ha-fade-in slot="headline" .delay=${500}
|
||||
><ha-skeleton-text></ha-skeleton-text
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
></ha-fade-in>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._createBackupLoading
|
||||
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
|
||||
><ha-skeleton></ha-skeleton
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
></ha-fade-in>`
|
||||
: html`<ha-switch
|
||||
slot="end"
|
||||
@@ -308,7 +307,6 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -154,12 +154,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
*/
|
||||
@property({ type: Boolean }) public empty = false;
|
||||
|
||||
/**
|
||||
* Show a loading state instead of the empty message until data is ready.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
/**
|
||||
@@ -498,7 +492,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.empty && !this.loading
|
||||
this.empty
|
||||
? html`<div class="center">
|
||||
<slot name="empty">${this.noDataText}</slot>
|
||||
</div>`
|
||||
@@ -520,7 +514,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.columns=${this.columns}
|
||||
.data=${this.data}
|
||||
.loading=${this.loading}
|
||||
.noDataText=${this.noDataText}
|
||||
.filter=${this.filter}
|
||||
.selectable=${this._selectMode}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import "../components/skeleton/ha-skeleton-text";
|
||||
|
||||
/** Placeholder shown in place of a text while onboarding translations load. */
|
||||
export const renderSkeleton = (variant: string): TemplateResult =>
|
||||
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
|
||||
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
|
||||
|
||||
export const skeletonStyles = css`
|
||||
.skeleton {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.skeleton.title {
|
||||
--ha-skeleton-text-width: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
.skeleton.line {
|
||||
--ha-skeleton-text-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.skeleton.headline {
|
||||
--ha-skeleton-text-width: 40%;
|
||||
width: 40%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.skeleton.chip {
|
||||
--ha-skeleton-text-width: 80px;
|
||||
width: 80px;
|
||||
}
|
||||
.skeleton.button {
|
||||
--ha-skeleton-text-width: 120px;
|
||||
width: 120px;
|
||||
}
|
||||
.skeleton.label {
|
||||
--ha-skeleton-text-width: 80px;
|
||||
width: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -123,8 +123,6 @@ const RATING_ICON = {
|
||||
8: mdiNumeric8,
|
||||
};
|
||||
|
||||
const MAX_RATING = 8;
|
||||
|
||||
const POLL_INTERVAL_SECONDS = 5;
|
||||
|
||||
@customElement("supervisor-app-info")
|
||||
@@ -1092,8 +1090,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.title` as LocalizeKeys
|
||||
),
|
||||
text: this.i18n.localize(
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.description`,
|
||||
{ max: MAX_RATING }
|
||||
`ui.panel.config.apps.dashboard.capability.${id}.description`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -35,12 +35,9 @@ class HaConfigDevices extends HassRouterPage {
|
||||
|
||||
@state() private _manifests: IntegrationManifest[] = [];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._loadData();
|
||||
}
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
}
|
||||
|
||||
protected updatePageEl(pageEl) {
|
||||
@@ -58,14 +55,8 @@ class HaConfigDevices extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _loadData() {
|
||||
await Promise.all([
|
||||
getConfigEntries(this.hass).then((configEntries) => {
|
||||
this._configEntries = configEntries;
|
||||
}),
|
||||
fetchIntegrationManifests(this.hass).then((manifests) => {
|
||||
this._manifests = manifests;
|
||||
}),
|
||||
]);
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
this._manifests = await fetchIntegrationManifests(this.hass);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
|
||||
import "../../../../../components/ha-dropdown-item";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { getSignedPath } from "../../../../../data/auth";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
|
||||
import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr";
|
||||
|
||||
@@ -108,8 +108,6 @@ export class HaConfigLabels extends LitElement {
|
||||
|
||||
@state() private _labels: LabelRegistryEntry[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
@@ -260,7 +258,6 @@ export class HaConfigLabels extends LitElement {
|
||||
.tabs=${configSections.areas}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${this._data(this._labels)}
|
||||
.loading=${this._loading}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
|
||||
has-fab
|
||||
.initialSorting=${this._activeSorting}
|
||||
@@ -324,11 +321,7 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchLabels() {
|
||||
try {
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
}
|
||||
|
||||
private _addLabel() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
@@ -188,10 +188,8 @@ class DialogSystemLogDetail extends LitElement {
|
||||
integration &&
|
||||
this._manifest === undefined &&
|
||||
reportTarget !== "frontend"
|
||||
? html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
? html`<ha-alert alert-type="info">
|
||||
<wa-skeleton effect="sheen"></wa-skeleton>
|
||||
</ha-alert>`
|
||||
: html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
@@ -423,24 +421,10 @@ class DialogSystemLogDetail extends LitElement {
|
||||
ha-alert p + p {
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
ha-skeleton-text {
|
||||
--ha-skeleton-text-width: 320px;
|
||||
}
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
ha-alert[alert-type="info"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--info-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
ha-alert[alert-type="warning"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--warning-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
wa-skeleton {
|
||||
height: 1em;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.contents {
|
||||
outline: none;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { clearStatistics, getStatisticLabel } from "../../../../data/recorder";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import type { DialogStatisticsFixParams } from "./show-dialog-statistics-fix";
|
||||
|
||||
@customElement("dialog-statistics-fix")
|
||||
|
||||
@@ -13,14 +13,7 @@ import {
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
css,
|
||||
type CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
@@ -74,7 +67,7 @@ import { getAreaTableColumn } from "../../common/data-table-columns";
|
||||
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistantRegistries } from "../../../../types";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import { fixStatisticsIssue } from "./fix-statistics";
|
||||
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
|
||||
|
||||
@@ -115,8 +108,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private filter = "";
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
@@ -155,12 +146,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@query("ha-input-search") private _searchInput!: HaInputSearch;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._validateStatistics();
|
||||
}
|
||||
protected firstUpdated() {
|
||||
this._validateStatistics();
|
||||
}
|
||||
|
||||
private _displayData = memoizeOne(
|
||||
@@ -567,7 +554,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
<ha-data-table
|
||||
.narrow=${this.narrow}
|
||||
.loading=${this._loading}
|
||||
.columns=${columns}
|
||||
.data=${this._displayData(
|
||||
this._data,
|
||||
@@ -736,42 +722,38 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _validateStatistics() {
|
||||
try {
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
|
||||
updateStatisticsIssues(this._api);
|
||||
updateStatisticsIssues(this._api);
|
||||
|
||||
const statsIds = new Set();
|
||||
const statsIds = new Set();
|
||||
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _clearSelected = async () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
/**
|
||||
* In periods where part of the sources' energy did not reach the home (grid
|
||||
* charging a battery, a battery exporting), per-source attribution is
|
||||
* ambiguous if multiple sources have data in the same period. Values that
|
||||
* round to 0 Wh are float noise from subtracting sums.
|
||||
* Rewrites single-source periods in place on `bySource`, and returns a
|
||||
* combined used map for multi-source periods. Returns undefined when no
|
||||
* combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsed(
|
||||
bySource: Record<string, Record<number, number>>,
|
||||
notUsed: Record<number, number>,
|
||||
used: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const combined: Record<number, number> = {};
|
||||
for (const [start, notUsedInPeriod] of Object.entries(notUsed)) {
|
||||
if (!round(notUsedInPeriod, 3)) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(bySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
bySource[source][start] = used[start];
|
||||
} else {
|
||||
Object.values(bySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
if (round(used[start], 3)) {
|
||||
combined[start] = used[start];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(combined).length > 0 ? combined : undefined;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* When battery is charging from grid, per-source import attribution is
|
||||
* ambiguous if multiple grid sources have data in the same period.
|
||||
* Rewrites single-source periods in place on `fromGridBySource`, and returns
|
||||
* a combined used-grid map for multi-source periods. Returns undefined when
|
||||
* no combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsedGrid(
|
||||
fromGridBySource: Record<string, Record<number, number>>,
|
||||
gridToBattery: Record<number, number>,
|
||||
usedGrid: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const used_grid: Record<number, number> = {};
|
||||
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(fromGridBySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
fromGridBySource[source][start] = usedGrid[start];
|
||||
} else {
|
||||
Object.values(fromGridBySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = usedGrid[start];
|
||||
}
|
||||
}
|
||||
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
|
||||
import { buildCombinedUsed } from "./energy-usage-graph-combined-used";
|
||||
|
||||
const colorPropertyMap = {
|
||||
to_grid: "--energy-grid-return-color",
|
||||
@@ -52,6 +52,7 @@ const colorPropertyMap = {
|
||||
from_grid: "--energy-grid-consumption-color",
|
||||
used_grid: "--energy-grid-consumption-color",
|
||||
used_solar: "--energy-solar-color",
|
||||
from_battery: "--energy-battery-out-color",
|
||||
used_battery: "--energy-battery-out-color",
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const stackOrder = {
|
||||
to_battery: 1,
|
||||
to_grid: 2,
|
||||
used_solar: 3,
|
||||
from_battery: 4,
|
||||
used_battery: 4,
|
||||
from_grid: 5,
|
||||
used_grid: 5,
|
||||
@@ -297,10 +299,12 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid: Record<string, string>;
|
||||
from_grid: Record<string, string>;
|
||||
to_battery: Record<string, string>;
|
||||
from_battery: Record<string, string>;
|
||||
} = {
|
||||
to_grid: {},
|
||||
from_grid: {},
|
||||
to_battery: {},
|
||||
from_battery: {},
|
||||
};
|
||||
|
||||
// Grid sources can be import-only or export-only; assign color indices by
|
||||
@@ -335,6 +339,10 @@ export class HuiEnergyUsageGraphCard
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
|
||||
{ name: source.name }
|
||||
);
|
||||
statLabels.from_battery[source.stat_energy_from] = this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
|
||||
{ name: source.name }
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -543,6 +551,7 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid: Record<string, string>;
|
||||
from_grid: Record<string, string>;
|
||||
to_battery: Record<string, string>;
|
||||
from_battery: Record<string, string>;
|
||||
},
|
||||
trackY: (v: number) => void,
|
||||
compare = false
|
||||
@@ -553,13 +562,16 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid?: Record<string, Record<number, number>>;
|
||||
to_battery?: Record<string, Record<number, number>>;
|
||||
from_grid?: Record<string, Record<number, number>>;
|
||||
from_battery?: Record<string, Record<number, number>>;
|
||||
used_grid?: Record<string, Record<number, number>>;
|
||||
used_solar?: Record<string, Record<number, number>>;
|
||||
used_battery?: Record<string, Record<number, number>>;
|
||||
} = {};
|
||||
|
||||
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
|
||||
if (!["to_grid", "from_grid", "to_battery"].includes(key)) {
|
||||
if (
|
||||
!["to_grid", "from_grid", "to_battery", "from_battery"].includes(key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sets: Record<string, Record<number, number>> = {};
|
||||
@@ -584,21 +596,34 @@ export class HuiEnergyUsageGraphCard
|
||||
combinedData[key] = sets;
|
||||
});
|
||||
|
||||
// Only add solar/battery consumption series when such a source is
|
||||
// actually configured, otherwise the legend shows empty solar/battery
|
||||
// entries for grid-only setups. Combined used_grid is a fallback for
|
||||
// multi-source battery charging; skip it when it has no points.
|
||||
// Only add the solar consumption series when solar is configured,
|
||||
// otherwise the legend shows an empty solar entry for grid-only setups.
|
||||
// Combined used_grid and used_battery are fallbacks for periods that
|
||||
// can't be split per source; skip them when they have no points.
|
||||
if (statIdsByCat.solar) {
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
}
|
||||
if (statIdsByCat.from_battery) {
|
||||
combinedData.used_battery = {
|
||||
used_battery: consumptionData.used_battery,
|
||||
};
|
||||
|
||||
if (combinedData.from_battery) {
|
||||
// Discharge that did not reach the home
|
||||
const batteryNotUsed: Record<number, number> = {};
|
||||
for (const start of summedData.timestamps) {
|
||||
batteryNotUsed[start] =
|
||||
(summedData.from_battery?.[start] ?? 0) -
|
||||
consumptionData.used_battery[start];
|
||||
}
|
||||
const used_battery = buildCombinedUsed(
|
||||
combinedData.from_battery,
|
||||
batteryNotUsed,
|
||||
consumptionData.used_battery
|
||||
);
|
||||
if (used_battery) {
|
||||
combinedData.used_battery = { used_battery };
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedData.from_grid && summedData.to_battery) {
|
||||
const used_grid = buildCombinedUsedGrid(
|
||||
const used_grid = buildCombinedUsed(
|
||||
combinedData.from_grid,
|
||||
consumptionData.grid_to_battery,
|
||||
consumptionData.used_grid
|
||||
|
||||
@@ -340,9 +340,6 @@ class HaRefreshTokens extends LitElement {
|
||||
ha-list-item-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
[slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export const loadVirtualizer = async () => {
|
||||
// The default flow layout is otherwise only fetched once there are items.
|
||||
await Promise.all([
|
||||
import("@lit-labs/virtualizer"),
|
||||
import("@lit-labs/virtualizer/layouts/flow.js"),
|
||||
]);
|
||||
await import("@lit-labs/virtualizer");
|
||||
};
|
||||
|
||||
@@ -3143,7 +3143,7 @@
|
||||
},
|
||||
"rating": {
|
||||
"title": "Security rating",
|
||||
"description": "This shows the security rating of the app on a scale from 1 to {max}. Higher is better."
|
||||
"description": "This shows the security rating of the app. Higher is better."
|
||||
},
|
||||
"host_network": {
|
||||
"title": "Host network",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Protects the energy usage graph from adding an empty combined Grid or
|
||||
* Battery legend item, while still combining sources when multiple grid
|
||||
* imports share a battery-charging period or multiple batteries share an
|
||||
* exporting period.
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { buildCombinedUsed } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-combined-used";
|
||||
|
||||
const t = 1_700_000_000_000;
|
||||
|
||||
describe("buildCombinedUsed", () => {
|
||||
it("does not add a combined series for a single grid source charging a battery", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
|
||||
});
|
||||
|
||||
it("combines overlapping grid sources and removes per-source points", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 6 },
|
||||
"sensor.grid_import_b": { [t]: 4 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
|
||||
|
||||
assert.deepEqual(result, { [t]: 7 });
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
|
||||
});
|
||||
|
||||
it("does not add a combined series when battery is present but not charging from grid", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, {}, { [t]: 10 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
|
||||
});
|
||||
|
||||
it("does not add a combined series when the home used none of the energy", () => {
|
||||
const fromBatteryBySource = {
|
||||
"sensor.battery_a_out": { [t]: 2 },
|
||||
"sensor.battery_b_out": { [t]: 1 },
|
||||
};
|
||||
|
||||
// All discharge exported; the model leaves 2.8e-17 as "used"
|
||||
const result = buildCombinedUsed(
|
||||
fromBatteryBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 2.7755575615628914e-17 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.isUndefined(fromBatteryBySource["sensor.battery_a_out"][t]);
|
||||
assert.isUndefined(fromBatteryBySource["sensor.battery_b_out"][t]);
|
||||
});
|
||||
|
||||
it("treats float noise in the unused energy as zero", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 0.06 },
|
||||
"sensor.grid_import_b": { [t]: 0.04 },
|
||||
};
|
||||
|
||||
// 0.06 + 0.04 + 0.4 solar - 0.4 to battery leaves 2.8e-17 "grid to battery"
|
||||
const result = buildCombinedUsed(
|
||||
fromGridBySource,
|
||||
{ [t]: 2.7755575615628914e-17 },
|
||||
{ [t]: 0.1 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import_a"][t], 0.06);
|
||||
assert.equal(fromGridBySource["sensor.grid_import_b"][t], 0.04);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Protects the energy usage graph from adding an empty combined Grid
|
||||
* legend item for single-source + battery setups, while still combining
|
||||
* sources when multiple grid imports share a battery-charging period.
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
|
||||
|
||||
const t = 1_700_000_000_000;
|
||||
|
||||
describe("buildCombinedUsedGrid", () => {
|
||||
it("does not add a combined series for a single grid source charging a battery", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
|
||||
});
|
||||
|
||||
it("combines overlapping grid sources and removes per-source points", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 6 },
|
||||
"sensor.grid_import_b": { [t]: 4 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { [t]: 7 });
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
|
||||
});
|
||||
|
||||
it("does not add a combined series when battery is present but not charging from grid", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user