Compare commits

..
Author SHA1 Message Date
Paul Bottein 73a88453db Pass no highlight when nothing is selected 2026-10-29 17:05:42 +01:00
Paul Bottein fba2451e61 Merge the chart range selection fixes into the statistic dialog 2026-10-29 17:05:37 +01:00
Paul Bottein c33a452976 Keep range selection on release outside the chart and simplify the highlight 2026-10-29 17:05:26 +01:00
Paul Bottein ec0533f8c5 Merge the chart range selection into the statistic dialog 2026-10-29 16:45:50 +01:00
Paul Bottein b1b9c41bc1 Add range selection and highlight to charts 2026-10-29 16:45:13 +01:00
Paul Bottein 685e7d51d2 Merge the zoom slider mode base into the statistic dialog 2026-10-29 16:39:23 +01:00
Paul Bottein 315afd8ac2 Replace hide-zoom-controls with a zoom slider mode on charts 2026-10-29 16:30:40 +01:00
Paul Bottein bc9d0b5227 Fix stale adjustments, zoom and outlier selection in the statistic dialog 2026-10-29 16:26:18 +01:00
Paul Bottein 99c85c3407 Show the last 7 days by default in the statistic dialog 2026-10-29 16:23:32 +01:00
Paul Bottein e04e639100 Always show the zoom slider and keep outliers and selection after adjusting 2026-10-29 16:10:29 +01:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Paul Bottein ca7b91af35 Show the graph loader only on slow loads 2026-10-29 15:49:51 +01:00
Paul Bottein ffd26ffc1a Add sum outliers, keep the graph while loading and fix review feedback 2026-10-29 15:48:10 +01:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
Paul Bottein bd4ff71d29 Open statistics in a dialog to view and correct their data 2026-10-29 14:57:51 +01:00
Paul Bottein 78f95bbb06 Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 14:36:46 +01:00
36 changed files with 1839 additions and 1290 deletions

No files matched your search

+1 -31
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,36 +91,6 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+1 -1
View File
@@ -40,7 +40,7 @@ Never run `tsc` or `pnpm lint:types` with file arguments. When `tsc` receives fi
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+1 -1
View File
@@ -101,7 +101,7 @@
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
+5 -5
View File
@@ -364,8 +364,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.8.0
version: 9.8.0
specifier: 9.8.1
version: 9.8.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -5205,8 +5205,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
[email protected].0:
resolution: {integrity: sha512-Nl5asoKQJwJ5a8N0U72jkSAUdO+hsY/T+Upp0VaZMUSt6A2gtdyvKfBIEYbpnYK3NP83Q/YGaC5XYkO5c08Img==}
[email protected].1:
resolution: {integrity: sha512-WvovbrSoYLTKfh6aY5SxSWPPy50/mLWLCihQL8G2N+2e3mTrg3QTQCAp4R32/LwbkRBDGj0XNs6JFj8e2IYEUw==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -12962,7 +12962,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+1 -1
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
+84 -9
View File
@@ -89,6 +89,9 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const CLICK_DRAG_TOLERANCE = 4;
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -163,15 +166,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// Dragging selects a time range instead of zooming
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -197,6 +205,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -450,6 +460,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectRange")) {
this._updateSelectRange();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -617,7 +630,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -901,6 +914,32 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.getZr().on("mousedown", (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
});
this.chart.getZr().on("click", (e: ECElementEvent) => {
const point = [e.offsetX, e.offsetY];
// A drag that ends on the plot is not a click
const dragged =
this._pointerDownX !== undefined &&
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
if (dragged || !this.chart?.containPixel("grid", point)) {
return;
}
const [x, y] = this.chart.convertFromPixel("grid", point) as number[];
fireEvent(this, "chart-grid-click", { x, y });
});
this.chart.on("brushEnd", (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
// The caller shows the selection, snapped to its data
this.chart?.dispatchAction({ type: "brush", areas: [] });
if (range) {
fireEvent(this, "chart-range-select", {
start: range[0],
end: range[1],
});
}
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
@@ -966,6 +1005,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectRange) {
this._updateSelectRange();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1017,7 +1059,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// A chart can switch type (e.g. pie and bar) without being rebuilt
private _updateDragToZoom() {
const active = !this._touchInput && this._hasZoomableXAxis();
const active =
!this._touchInput && this._hasZoomableXAxis() && !this.selectRange;
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1029,6 +1072,33 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _updateSelectRange() {
if (!this.chart) {
return;
}
if (this.selectRange) {
this.chart.setOption({
brush: { xAxisIndex: 0, transformable: false },
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: this.selectRange ? "lineX" : false,
brushMode: "single",
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (this.selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1064,9 +1134,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -1532,7 +1605,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleClickZoom = (e: ECElementEvent) => {
if (!this.chart) {
if (!this.chart || this.selectRange) {
return;
}
// Handle sankey chart double-click zoom
@@ -1745,7 +1818,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// it, so a quick drag out would lose the release and its selection
if (
ev.type === "pointerdown" &&
this._dragToZoom &&
(this._dragToZoom || this.selectRange) &&
this._isZoomPress(ev)
) {
this.chart
@@ -2317,6 +2390,8 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-grid-click": { x: number; y: number };
"chart-range-select": { start: number; end: number };
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
@@ -24,6 +24,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,6 +22,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
+79 -3
View File
@@ -5,7 +5,7 @@ import type {
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@@ -49,7 +49,11 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +132,19 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: {
start: number;
end: number;
};
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -243,9 +260,15 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.data=${this._highlightedChartData(
this._chartData,
this.highlight,
getComputedStyle(this).getPropertyValue("--primary-color").trim()
)}
.options=${this._chartOptions}
.height=${this.height}
.selectRange=${this.selectRange}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +282,59 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
private _highlightedChartData = memoizeOne(
(
data: (LineSeriesOption | BarSeriesOption)[],
highlight: { start: number; end: number } | undefined,
color: string
): (LineSeriesOption | BarSeriesOption)[] => {
if (!data.length) {
return data;
}
if (data[0].type === "bar") {
if (!highlight) {
return data;
}
// Bars outside the highlight fade, so the highlighted ones stand out.
// Some points are already objects, like negative bars with round caps.
return data.map((series) => ({
...series,
data: (series.data as any[]).map((item) => {
const point =
item && typeof item === "object" && "value" in item
? item
: { value: item };
// Current state bars only carry their time
const start = new Date(point.value[2] ?? point.value[0]).getTime();
return start >= highlight.start && start < highlight.end
? item
: {
...point,
itemStyle: { ...point.itemStyle, opacity: 0.3 },
};
}),
})) as BarSeriesOption[];
}
// Every series gets an area, empty when not highlighted, as the chart
// merges series and would keep an old one
return data.map((series, index) => ({
...series,
markArea: {
silent: true,
itemStyle: { color, opacity: 0.15 },
data:
highlight && index === 0
? [[{ xAxis: highlight.start }, { xAxis: highlight.end }]]
: [],
},
}));
}
);
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
@@ -1,6 +1,5 @@
import { TZDate } from "@date-fns/tz";
import type { ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
@@ -343,7 +342,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
private _setDateRange(ev: HASSDomEvent<{ index: number }>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
@@ -107,6 +107,9 @@ export class HaDateRangePicker extends LitElement {
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
// Added after the default presets
@property({ attribute: false }) public extraRanges?: DateRangePickerRanges;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "time-picker", type: Boolean })
@@ -166,7 +169,8 @@ export class HaDateRangePicker extends LitElement {
if (
changedProps.has("_i18n") ||
changedProps.has("_hassConfig") ||
changedProps.has("extendedPresets")
changedProps.has("extendedPresets") ||
changedProps.has("extraRanges")
) {
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
@@ -178,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
this._ranges = ranges;
this._ranges = { ...ranges, ...this.extraRanges };
}
}
-52
View File
@@ -1,52 +0,0 @@
import { css } from "lit";
import { RadioListItemBase } from "@material/mwc-list/mwc-radio-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-radio-list-item")
export class HaRadioListItem extends RadioListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--radio-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-list-item": HaRadioListItem;
}
}
+1 -1
View File
@@ -349,7 +349,7 @@ export const statisticsMetaHasType = (
};
export const adjustStatisticsSum = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
statistic_id: string,
start_time: number,
adjustment: number,
@@ -73,6 +73,10 @@ const mountPartitionLabel = ({
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
};
// Supervisor accepts only ASCII letters, digits and underscores.
const mountNameFromLabel = (label?: string): string =>
(label ?? "").replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_");
const requiredFieldsFilled = (
schema: readonly HaFormSchema[],
data?: MountFormData
@@ -305,6 +309,8 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _originalType?: SupervisorMountType;
private _suggestedName?: string;
@state() private _diskIdentity?: string;
public connectedCallback(): void {
@@ -529,7 +535,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent) {
this._validationError = {};
this._validationWarning = {};
const previous = this._data;
this._data = ev.detail.value;
this._suggestName(previous);
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
this._validationError.name = "invalid_name";
}
@@ -556,6 +564,32 @@ class ViewMountDialog extends DirtyStateProviderMixin<
);
}
// Name a new disk mount after the partition's volume label, unless the user
// typed a name of their own.
private _suggestName(previous?: MountFormData) {
const data = this._data;
if (data?.name !== previous?.name) {
this._suggestedName = undefined;
}
if (
this._existing ||
data?.type !== SupervisorMountType.DISK ||
(data.device === previous?.device &&
previous?.type === SupervisorMountType.DISK) ||
(data.name && data.name !== this._suggestedName)
) {
return;
}
const name = mountNameFromLabel(
this._partitions?.find((entry) => entry.partition.device === data.device)
?.partition.label
);
if (name !== (data.name ?? "")) {
data.name = name;
}
this._suggestedName = name;
}
private async _connectMount(ev) {
const progressButton = ev.target as HaProgressButton;
this._error = undefined;
File diff suppressed because it is too large. Load diff
@@ -1,665 +0,0 @@
import { formatISO9075 } from "date-fns";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { formatNumberWithUnit } from "../../../../common/number/format_number";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import "../../../../components/ha-selector/ha-selector-datetime";
import "../../../../components/ha-selector/ha-selector-number";
import "../../../../components/ha-svg-icon";
import type { StatisticValue } from "../../../../data/recorder";
import {
adjustStatisticsSum,
fetchStatistics,
getDisplayUnit,
} from "../../../../data/recorder";
import type {
DateTimeSelector,
NumberSelector,
} from "../../../../data/selector";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import type { DialogStatisticsAdjustSumParams } from "./show-dialog-statistics-adjust-sum";
interface CombinedStat {
hour: StatisticValue | null;
fiveMin: StatisticValue[];
}
interface FetchedStats {
hour: StatisticValue[];
fiveMin: StatisticValue[];
}
interface AdjustState {
amount: number | undefined;
}
@customElement("dialog-statistics-adjust-sum")
export class DialogStatisticsFixUnsupportedUnitMetadata extends DirtyStateProviderMixin<AdjustState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DialogStatisticsAdjustSumParams;
@state() private _open = false;
@state() private _busy = false;
@state() private _moment?: string;
@state() private _stats5min?: StatisticValue[];
@state() private _statsHour?: StatisticValue[];
@state() private _chosenStat?: StatisticValue;
@state() private _amount?: number;
private _showingOutliers = false;
// Adjustments made while the dialog is open, keyed by period. The recorder
// processes adjustments asynchronously, so a fetch right after adjusting can
// still return the old values.
private _adjustedChanges = new Map<string, number>();
// Incremented for every fetch, so results of outdated fetches are ignored
private _fetchId = 0;
private _dateTimeSelector: DateTimeSelector = {
datetime: {},
};
private _precision = 2;
private _amountSelector = memoizeOne(
(
unit_of_measurement: string | undefined,
precision: number
): NumberSelector => ({
number: {
step: 10 ** -precision,
unit_of_measurement,
mode: "box",
},
})
);
public showDialog(params: DialogStatisticsAdjustSumParams): void {
this._params = params;
this._open = true;
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
// Here we create a date with minutes set to %5
const now = new Date();
now.setMinutes(now.getMinutes() - (now.getMinutes() % 5), 0);
this._moment = formatISO9075(now);
this._showingOutliers = false;
this._adjustedChanges.clear();
this._refreshStats();
const entry = this.hass.entities[params.statistic.statistic_id];
this._precision = Math.max(entry?.display_precision ?? 0, 2);
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._moment = undefined;
this._stats5min = undefined;
this._statsHour = undefined;
this._amount = undefined;
this._chosenStat = undefined;
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
this._busy = false;
this._showingOutliers = false;
this._adjustedChanges.clear();
this._fetchId++;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
let content: TemplateResult;
let footer: TemplateResult;
if (!this._chosenStat) {
content = this._renderPickStatistic();
footer = html`
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._showOutliers}
>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.outliers"
)}
</ha-button>
<ha-button slot="primaryAction" data-dialog="close">
${this.hass.localize("ui.common.close")}
</ha-button>
`;
} else {
content = this._renderAdjustStat();
footer = html`
<ha-button
slot="secondaryAction"
.disabled=${this._busy}
@click=${this._clearChosenStatistic}
appearance="plain"
>
${this.hass.localize("ui.common.back")}</ha-button
>
<ha-button
slot="primaryAction"
.disabled=${this._busy || !this.isDirtyState}
@click=${this._fixIssue}
>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.adjust"
)}</ha-button
>
`;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.title"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${content}
<ha-dialog-footer slot="footer">${footer}</ha-dialog-footer>
</ha-dialog>
`;
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (changedProps.size !== 1 || !changedProps.has("hass")) {
return true;
}
// We only respond to hass changes if the translations or locale changed
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
return (
!oldHass ||
oldHass.localize !== this.hass.localize ||
oldHass.locale !== this.hass.locale
);
}
private _renderPickStatistic() {
let stats: TemplateResult;
if (!this._stats5min || !this._statsHour) {
stats = html`<ha-spinner></ha-spinner>`;
} else if (this._statsHour.length < 1 && this._stats5min.length < 1) {
stats = html`<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.no_statistics_found"
)}
</p>`;
} else {
const data =
this._stats5min.length >= 1 ? this._stats5min : this._statsHour;
const unit = getDisplayUnit(
this.hass.states,
this._params!.statistic.statistic_id,
this._params!.statistic
);
const rows: TemplateResult[] = [];
for (const stat of data) {
const digits = this._fractionDigits(stat.change);
const change = formatNumberWithUnit(
stat.change!,
unit,
this.hass.locale,
{
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}
);
rows.push(html`
<ha-list-item
twoline
hasMeta
.stat=${stat}
@click=${this._setChosenStatistic}
>
<span>${change}</span>
<span slot="secondary">
${formatDateTime(
new Date(stat.start),
this.hass.locale,
this.hass.config
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`);
}
stats = html`${rows}`;
}
return html`
<div class="text-content">
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.info_text_1"
)}
</div>
<div class="text-content">
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
)}</b
>
${this._params!.statistic.statistic_id}
</div>
<ha-selector-datetime
.label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.pick_a_time"
)}
.selector=${this._dateTimeSelector}
.value=${this._moment}
@value-changed=${this._dateTimeSelectorChanged}
></ha-selector-datetime>
<div class="stat-list">${stats}</div>
`;
}
private _clearChosenStatistic() {
this._chosenStat = undefined;
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
}
// Small changes get more digits, so they do not round to zero.
private _fractionDigits(value: number | null | undefined): number {
if (!value || Math.abs(value) >= 10 ** -this._precision) {
return this._precision;
}
return Math.min(Math.ceil(-Math.log10(Math.abs(value))) + 1, 20);
}
private _setChosenStatistic(ev) {
const stat = ev.currentTarget.stat;
const multiple = 10 ** this._fractionDigits(stat.change);
const growth = Math.round(stat.change! * multiple) / multiple;
this._chosenStat = stat;
this._amount = growth;
this._initDirtyTracking({ type: "deep" }, { amount: growth });
}
private _dateTimeSelectorChanged(ev) {
this._moment = ev.detail.value;
this._showingOutliers = false;
this._refreshStats();
}
private _showOutliers() {
this._showingOutliers = true;
this._refreshStats();
}
private async _refreshStats(): Promise<void> {
const fetchId = ++this._fetchId;
this._stats5min = undefined;
this._statsHour = undefined;
try {
const stats = this._showingOutliers
? await this._fetchOutliers()
: await this._fetchStats();
if (fetchId !== this._fetchId) {
return;
}
this._statsHour = stats.hour;
this._stats5min = stats.fiveMin;
} catch (err: any) {
if (fetchId !== this._fetchId) {
return;
}
this._stats5min = [];
this._statsHour = [];
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_loading_statistics",
{ message: err.message || err }
),
});
}
}
private _applyAdjustedChanges(stats: StatisticValue[]): StatisticValue[] {
if (!this._adjustedChanges.size) {
return stats;
}
return stats.map((stat) => {
const change = this._adjustedChanges.get(`${stat.start}-${stat.end}`);
return change === undefined ? stat : { ...stat, change };
});
}
private _renderAdjustStat() {
const unit = getDisplayUnit(
this.hass.states,
this._params!.statistic.statistic_id,
this._params!.statistic
);
return html`
<div class="text-content">
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
)}</b
>
${this._params!.statistic.statistic_id}
</div>
<div class="table-row">
<span
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.start"
)}</span
>
<span
>${formatDateTime(
new Date(this._chosenStat!.start),
this.hass.locale,
this.hass.config
)}</span
>
</div>
<div class="table-row">
<span
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.end"
)}</span
>
<span
>${formatDateTime(
new Date(this._chosenStat!.end),
this.hass.locale,
this.hass.config
)}</span
>
</div>
<ha-selector-number
.label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.new_value"
)}
.selector=${this._amountSelector(
unit || undefined,
this._fractionDigits(this._chosenStat!.change)
)}
.value=${this._amount}
.disabled=${this._busy}
@value-changed=${this._amountChanged}
></ha-selector-number>
`;
}
private _amountChanged(ev) {
this._amount = ev.detail.value;
this._updateDirtyState({ amount: this._amount });
}
private async _fetchStats(): Promise<FetchedStats> {
const statId = this._params!.statistic.statistic_id;
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
// Here we convert it to an ISO string.
const moment = new Date(this._moment!.replace(" ", "T"));
// Search 2 hours before and 3 hours after chosen time
const hourStatStart = new Date(moment.getTime());
hourStatStart.setTime(hourStatStart.getTime() - 2 * 3600 * 1000);
const hourStatEnd = new Date(moment.getTime());
hourStatEnd.setTime(hourStatEnd.getTime() + 3 * 3600 * 1000);
const statsHourData = await fetchStatistics(
this.hass.callWS,
hourStatStart,
hourStatEnd,
[statId],
"hour"
);
const statsHour =
statId in statsHourData
? this._applyAdjustedChanges(statsHourData[statId].slice(0, 5))
: [];
// Can't have 5 min data if no hourly data
if (statsHour.length === 0) {
return { hour: [], fiveMin: [] };
}
// Search 10 minutes before and 15 minutes after chosen time
const minStatStart = new Date(moment.getTime());
minStatStart.setTime(minStatStart.getTime() - 10 * 60 * 1000);
const minStatEnd = new Date(moment.getTime());
minStatEnd.setTime(minStatEnd.getTime() + 15 * 60 * 1000);
const stats5MinData = await fetchStatistics(
this.hass.callWS,
minStatStart,
minStatEnd,
[statId],
"5minute"
);
return {
hour: statsHour,
fiveMin:
statId in stats5MinData
? this._applyAdjustedChanges(stats5MinData[statId].slice(0, 5))
: [],
};
}
private async _fetchOutliers(): Promise<FetchedStats> {
const statId = this._params!.statistic.statistic_id;
// Get all the data
const start = new Date(0);
const end = new Date();
const statsHourData = await fetchStatistics(
this.hass.callWS,
start,
end,
[statId],
"hour"
);
const statsHour =
statId in statsHourData
? this._applyAdjustedChanges(statsHourData[statId])
: [];
if (statsHour.length === 0) {
return { hour: [], fiveMin: [] };
}
const stats5MinData = await fetchStatistics(
this.hass.callWS,
start,
end,
[statId],
"5minute"
);
const stats5Min =
statId in stats5MinData
? this._applyAdjustedChanges(stats5MinData[statId])
: [];
// First datapoint of 5 minute data in the history is always junk since it counts the entire sum
// as the change, which we don't want here.
stats5Min.shift();
const combinedStatsData: CombinedStat[] = [];
statsHour.forEach((s) => {
combinedStatsData.push({ hour: s, fiveMin: [] });
});
const lasthour: CombinedStat = { hour: null, fiveMin: [] };
let i = 0;
stats5Min.forEach((s) => {
let matched = false;
for (i; i < combinedStatsData.length; i++) {
const hour = combinedStatsData[i].hour;
if (hour && s.start >= hour.start && s.end <= hour.end) {
combinedStatsData[i].fiveMin.push(s);
matched = true;
break;
}
}
if (!matched) {
lasthour.fiveMin.push(s);
}
});
combinedStatsData.push(lasthour);
let statsOutliers: StatisticValue[] = [];
let min = 0;
const numOutliers = 10;
// Track the top 10 values.
const addOutlier = (s) => {
const val = Math.abs(s.change ?? 0);
if (statsOutliers.length < numOutliers || val > min) {
statsOutliers.push(s);
statsOutliers = statsOutliers.sort(
(a, b) => Math.abs(b.change ?? 0) - Math.abs(a.change ?? 0)
);
statsOutliers = statsOutliers.slice(0, numOutliers);
min = statsOutliers[statsOutliers.length - 1].change ?? 0;
}
};
// If an hour has incomplete five minute data, add the hour value
// Otherwise, add the 5 minute values and ignore the hour value
combinedStatsData.forEach((c) => {
if (c.fiveMin.length !== 12 && c.hour) {
addOutlier(c.hour);
} else {
c.fiveMin.forEach((s) => {
addOutlier(s);
});
}
});
// Outliers are a possible mix of hour/5minute data, but the distinction
// is not relevant here, as long as only one array is populated.
return { hour: statsOutliers, fiveMin: [] };
}
private async _fixIssue(): Promise<void> {
const params = this._params!;
const chosenStat = this._chosenStat!;
const amount = this._amount!;
const unit = getDisplayUnit(
this.hass.states,
params.statistic.statistic_id,
params.statistic
);
this._busy = true;
try {
await adjustStatisticsSum(
this.hass,
params.statistic.statistic_id,
chosenStat.start,
// The shown value is rounded, so compare with the stored value.
amount - chosenStat.change!,
unit || null
);
} catch (err: any) {
// Report the error even if the dialog was closed or reopened while
// adjusting, but leave the state of a newer invocation alone
if (this._params === params) {
this._busy = false;
}
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_sum_adjusted",
{ message: err.message || err }
),
});
return;
}
showToast(this, {
message: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.sum_adjusted"
),
});
// The dialog was closed or reopened while adjusting
if (this._params !== params) {
return;
}
this._adjustedChanges.set(`${chosenStat.start}-${chosenStat.end}`, amount);
this._busy = false;
this._clearChosenStatistic();
this._refreshStats();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
.text-content,
ha-selector-datetime,
ha-selector-number {
margin-bottom: var(--ha-space-5);
}
ha-list-item {
margin: 0 -24px;
--mdc-list-side-padding: 24px;
}
.table-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--ha-space-5);
}
.stat-list {
margin-top: var(--ha-space-2);
min-height: 360px;
display: flex;
flex-direction: column;
}
.stat-list ha-spinner {
margin: 0 auto;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-statistics-adjust-sum": DialogStatisticsFixUnsupportedUnitMetadata;
}
}
@@ -1,277 +0,0 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import "../../../../components/chart/statistics-chart";
import "../../../../components/date-picker/ha-date-range-picker";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-spinner";
import {
apiContext,
internationalizationContext,
} from "../../../../data/context";
import type { Statistics, StatisticType } from "../../../../data/recorder";
import {
clearStatistics,
fetchStatistics,
StatisticMeanType,
} from "../../../../data/recorder";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import { showToast } from "../../../../util/toast";
import type { DialogStatisticsClearRangeParams } from "./show-dialog-statistics-clear-range";
// Longer ranges use hourly statistics to keep the preview fast.
const MAX_5MINUTE_PREVIEW_MS = 3 * 24 * 60 * 60 * 1000;
type PreviewPeriod = "5minute" | "hour";
// The recorder also clears the period in which the range starts, so the
// preview starts at the beginning of that period.
const periodStart = (date: Date, period: PreviewPeriod) => {
const periodMs = (period === "hour" ? 60 : 5) * 60 * 1000;
return new Date(Math.floor(date.getTime() / periodMs) * periodMs);
};
@customElement("dialog-statistics-clear-range")
class DialogStatisticsClearRange extends DialogMixin<DialogStatisticsClearRangeParams>(
LitElement
) {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _start = new Date();
@state() private _end = new Date();
@state() private _statistics?: Statistics;
@state() private _previewStart?: Date;
@state() private _busy = false;
@state() private _error?: string;
// Incremented for every fetch, so results of outdated fetches are ignored
private _fetchId = 0;
connectedCallback() {
super.connectedCallback();
this._end.setMinutes(
this._end.getMinutes() - (this._end.getMinutes() % 5),
0,
0
);
this._start = new Date(this._end.getTime() - 24 * 60 * 60 * 1000);
this._fetchStatistics();
}
private get _validRange() {
return this._start.getTime() < this._end.getTime();
}
private get _statTypes(): StatisticType[] {
return this.params!.statistic.mean_type !== StatisticMeanType.NONE
? ["mean", "min", "max"]
: ["change"];
}
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
const statisticId = this.params.statistic.statistic_id;
const hasData = Boolean(this._statistics?.[statisticId]?.length);
return html`
<ha-dialog
open
width="large"
.headerTitle=${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
)}
.preventScrimClose=${this._busy}
>
<p>
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.info_text",
{ name: html`<b>${this.params.name}</b>` }
)}
</p>
<ha-date-range-picker
time-picker
.startDate=${this._start}
.endDate=${this._end}
.disabled=${this._busy}
@value-changed=${this._rangeChanged}
></ha-date-range-picker>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
!this._validRange
? html`<ha-alert alert-type="error">
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.invalid_range"
)}
</ha-alert>`
: !this._statistics
? html`<div class="preview center">
<ha-spinner></ha-spinner>
</div>`
: !hasData
? html`<div class="preview center">
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.no_statistics_found"
)}
</div>`
: html`<div class="preview">
<statistics-chart
.statisticsData=${this._statistics}
.metadata=${{ [statisticId]: this.params.statistic }}
.names=${{ [statisticId]: this.params.name }}
.statTypes=${this._statTypes}
.chartType=${
this._statTypes[0] === "change" ? "bar" : "line"
}
.startTime=${this._previewStart}
.endTime=${this._end}
hide-legend
.clickForMoreInfo=${false}
height="240px"
></statistics-chart>
</div>`
}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
.disabled=${this._busy}
@click=${this.closeDialog}
>
${localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
variant="danger"
.loading=${this._busy}
.disabled=${this._busy || !this._validRange || !hasData}
@click=${this._delete}
>
${localize("ui.common.delete")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _rangeChanged(
ev: HASSDomEvent<{ value: { startDate: Date; endDate: Date } }>
) {
this._start = ev.detail.value.startDate;
this._end = ev.detail.value.endDate;
this._fetchStatistics();
}
private async _fetchStatistics() {
const fetchId = ++this._fetchId;
this._statistics = undefined;
this._error = undefined;
if (!this._validRange) {
return;
}
const statisticId = this.params!.statistic.statistic_id;
const fetchPreview = async (period: PreviewPeriod) => {
const start = periodStart(this._start, period);
const statistics = await fetchStatistics(
this._api.callWS,
start,
this._end,
[statisticId],
period,
undefined,
this._statTypes.includes("change") ? ["change"] : ["mean", "min", "max"]
);
return { start, statistics };
};
try {
const shortRange =
this._end.getTime() - this._start.getTime() <= MAX_5MINUTE_PREVIEW_MS;
let preview = await fetchPreview(shortRange ? "5minute" : "hour");
// Short term statistics are purged before the hourly statistics.
if (shortRange && !preview.statistics[statisticId]?.length) {
preview = await fetchPreview("hour");
}
if (fetchId === this._fetchId) {
this._previewStart = preview.start;
this._statistics = preview.statistics;
}
} catch (err: any) {
if (fetchId === this._fetchId) {
this._statistics = {};
this._error = err.message || err;
}
}
}
private async _delete() {
this._busy = true;
this._error = undefined;
try {
await clearStatistics(this._api, [this.params!.statistic.statistic_id], {
start: this._start,
end: this._end,
});
} catch (err: any) {
this._busy = false;
this._error = err.message || err;
return;
}
showToast(this, {
message: this._i18n.localize(
"ui.panel.config.tools.tabs.statistics.clear_range.deleted"
),
});
this.closeDialog();
}
static styles = [
haStyleDialog,
css`
p {
margin-top: 0;
}
ha-date-range-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
.preview {
min-height: 240px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
color: var(--secondary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-statistics-clear-range": DialogStatisticsClearRange;
}
}
@@ -2,6 +2,30 @@ import type { StatisticsValidationResult } from "../../../../data/recorder";
import { showFixStatisticsDialog } from "./show-dialog-statistics-fix";
import { showFixStatisticsUnitsChangedDialog } from "./show-dialog-statistics-fix-units-changed";
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
no_state: 0,
entity_no_longer_recorded: 1,
entity_not_recorded: 1,
state_class_removed: 2,
units_changed: 3,
mean_type_changed: 4,
};
export const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
"no_state",
"entity_no_longer_recorded",
"state_class_removed",
"units_changed",
"mean_type_changed",
];
export const sortStatisticsIssues = (issues: StatisticsValidationResult[]) =>
[...issues].sort(
(itemA, itemB) =>
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
);
export const fixStatisticsIssue = async (
element: HTMLElement,
issue: StatisticsValidationResult
@@ -0,0 +1,27 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type {
StatisticsMetaData,
StatisticsValidationResult,
} from "../../../../data/recorder";
export const loadStatisticDialog = () => import("./dialog-statistic");
export interface DialogStatisticParams {
statistic: StatisticsMetaData;
name: string;
area?: string;
device?: string;
issues?: StatisticsValidationResult[];
issueFixedCallback?: () => void;
}
export const showStatisticDialog = (
element: HTMLElement,
dialogParams: DialogStatisticParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistic",
dialogImport: loadStatisticDialog,
dialogParams,
});
};
@@ -1,20 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { StatisticsMetaData } from "../../../../data/recorder";
export const loadAdjustSumDialog = () =>
import("./dialog-statistics-adjust-sum");
export interface DialogStatisticsAdjustSumParams {
statistic: StatisticsMetaData;
}
export const showStatisticsAdjustSumDialog = (
element: HTMLElement,
detailParams: DialogStatisticsAdjustSumParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistics-adjust-sum",
dialogImport: loadAdjustSumDialog,
dialogParams: detailParams,
});
};
@@ -1,21 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { StatisticsMetaData } from "../../../../data/recorder";
export const loadClearRangeDialog = () =>
import("./dialog-statistics-clear-range");
export interface DialogStatisticsClearRangeParams {
statistic: StatisticsMetaData;
name: string;
}
export const showStatisticsClearRangeDialog = (
element: HTMLElement,
dialogParams: DialogStatisticsClearRangeParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistics-clear-range",
dialogImport: loadClearRangeDialog,
dialogParams,
});
};
@@ -2,10 +2,8 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiDeleteClock,
mdiFormatListChecks,
mdiMenuDown,
mdiSlopeUphill,
mdiTableCog,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
@@ -28,11 +26,9 @@ import { consume } from "../../../../common/decorators/consume";
import { QueryParamsController } from "../../../../common/controllers/query-params-controller";
import { transform } from "../../../../common/decorators/transform";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import {
computeDeviceName,
@@ -87,26 +83,7 @@ import type {
HomeAssistantRegistries,
} from "../../../../types";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { fixStatisticsIssue } from "./fix-statistics";
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
import { showStatisticsClearRangeDialog } from "./show-dialog-statistics-clear-range";
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
no_state: 0,
entity_no_longer_recorded: 1,
entity_not_recorded: 1,
state_class_removed: 2,
units_changed: 3,
mean_type_changed: 4,
};
const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
"no_state",
"entity_no_longer_recorded",
"state_class_removed",
"units_changed",
"mean_type_changed",
];
import { showStatisticDialog } from "./show-dialog-statistic";
type StatisticData = StatisticsMetaData & {
issues?: StatisticsValidationResult[];
@@ -318,70 +295,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
localize("ui.panel.config.tools.tabs.statistics.no_issue")
}`,
},
fix: {
title: "",
label: localize("ui.panel.config.tools.tabs.statistics.fix_issue.fix"),
type: "icon",
template: (statistic) =>
html`${
statistic.issues
? html`<ha-button
@click=${this._fixIssue}
.data=${statistic.issues}
appearance="plain"
size="s"
>
${localize(
statistic.issues.some((issue) =>
FIXABLE_ISSUES.includes(issue.type)
)
? "ui.panel.config.tools.tabs.statistics.fix_issue.fix"
: "ui.panel.config.tools.tabs.statistics.fix_issue.info"
)}
</ha-button>`
: "—"
}`,
minWidth: "113px",
maxWidth: "113px",
showNarrow: true,
},
clear_range: {
title: "",
label: localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
),
type: "icon-button",
showNarrow: true,
template: (statistic) => html`
<ha-icon-button
.label=${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
)}
.path=${mdiDeleteClock}
.statistic=${statistic}
@click=${this._showStatisticsClearRangeDialog}
></ha-icon-button>
`,
},
actions: {
title: "",
label: localize("ui.panel.config.tools.tabs.statistics.adjust_sum"),
type: "icon-button",
showNarrow: true,
template: (statistic) =>
statistic.has_sum
? html`
<ha-icon-button
.label=${localize(
"ui.panel.config.tools.tabs.statistics.adjust_sum"
)}
.path=${mdiSlopeUphill}
.statistic=${statistic}
@click=${this._showStatisticsAdjustSumDialog}
></ha-icon-button>
`
: nothing,
},
})
);
@@ -825,35 +738,28 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
this._dataTable.selectAll((statistic) => statistic.issues);
}
private _showStatisticsAdjustSumDialog(
ev: HASSDomCurrentTargetEvent<HTMLElement & { statistic: StatisticData }>
) {
ev.stopPropagation();
showStatisticsAdjustSumDialog(this, {
statistic: ev.currentTarget.statistic,
});
}
private _showStatisticsClearRangeDialog(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { statistic: DisplayedStatisticData }
>
) {
ev.stopPropagation();
const statistic = ev.currentTarget.statistic;
showStatisticsClearRangeDialog(this, {
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const statistic = this._displayData(
this._data,
this._i18n.localize,
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._formatEntityName
).find((stat) => stat.statistic_id === ev.detail.id);
if (!statistic) {
return;
}
showStatisticDialog(this, {
statistic,
name: statistic.displayName,
area: statistic.area,
device: statistic.device,
issues: statistic.issues,
issueFixedCallback: () => this._validateStatistics(),
});
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const id = ev.detail.id;
if (id in this._states) {
fireEvent(this, "hass-more-info", { entityId: id });
}
}
private async _validateStatistics() {
try {
const [statisticIds, issues] = await Promise.all([
@@ -918,21 +824,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
});
};
private _fixIssue = async (
ev: HASSDomCurrentTargetEvent<
HTMLElement & { data: StatisticsValidationResult[] }
>
) => {
const issues = ev.currentTarget.data.sort(
(itemA, itemB) =>
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
);
const issue = issues[0];
await fixStatisticsIssue(this, issue);
this._validateStatistics();
};
protected supportedShortcuts(): SupportedShortcuts {
return {
f: () => this._searchInput.focus(),
@@ -28,7 +28,7 @@ import type { ContextType } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import {
@@ -78,7 +78,6 @@ import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../types";
import { animationStyles } from "../../../resources/theme/animations.globals";
const RANGE_KEYS: DateRange[] = [
"today",
@@ -151,6 +150,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
@state() private _loading = false;
@state() private _loadingLeaving = false;
@query(".loading-indicator") private _loadingIndicator?: HTMLElement;
private _loadingTimer?: ReturnType<typeof setTimeout>;
private _resizeObserver?: ResizeObserver;
@@ -379,10 +382,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
: nothing
}
${
this._loading
this._loading || this._loadingLeaving
? html`<ha-spinner
class="loading-indicator"
class=${classMap({
"loading-indicator": true,
leaving: this._loadingLeaving,
})}
size="small"
@transitionend=${this._loadingTransitionDone}
@transitioncancel=${this._loadingTransitionDone}
></ha-spinner>`
: nothing
}
@@ -727,6 +735,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
private _updateDates(energyData: EnergyData): void {
clearTimeout(this._loadingTimer);
if (this._loading) {
this._loadingLeaving = true;
this._removeLoadingIndicatorWithoutTransition();
}
this._loading = false;
this._compare = energyData.startCompare !== undefined;
this._startDate = energyData.start;
@@ -759,10 +771,30 @@ export class HuiEnergyPeriodSelector extends LitElement {
// Basically to ensure there's no "flash loading" when data is loaded quickly
clearTimeout(this._loadingTimer);
this._loadingTimer = setTimeout(() => {
this._loadingLeaving = false;
this._loading = true;
}, 200);
}
private async _removeLoadingIndicatorWithoutTransition() {
await this.updateComplete;
// No transition starts when the spinner was still at opacity 0
this._removeIdleLoadingIndicator();
}
private _loadingTransitionDone(ev: TransitionEvent) {
if (ev.propertyName === "opacity") {
this._removeIdleLoadingIndicator();
}
}
private _removeIdleLoadingIndicator() {
// Reversing the fade-in cancels a transition while the fade-out runs
if (!this._loadingIndicator?.getAnimations().length) {
this._loadingLeaving = false;
}
}
private _getDatePickerPlacement = memoizeOne(
(
verticalOpeningDirection: VerticalOpeningDirection | undefined,
@@ -778,7 +810,6 @@ export class HuiEnergyPeriodSelector extends LitElement {
);
static styles = css`
${animationStyles}
:host {
display: block;
}
@@ -843,7 +874,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
position: absolute;
right: var(--ha-space-2);
align-items: center;
animation: fade-in var(--ha-animation-duration-normal) ease-in-out;
transition: opacity var(--ha-animation-duration-normal) ease-in-out;
}
@starting-style {
.loading-indicator {
opacity: 0;
}
}
.loading-indicator.leaving {
opacity: 0;
}
.date-actions .overflow {
display: flex;
@@ -1,3 +1,4 @@
import { navigate } from "../../../common/navigate";
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -13,8 +14,10 @@ import {
} from "../../../data/lovelace/config/types";
import { isStrategyView } from "../../../data/lovelace/config/view";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { getPanelTitleFromUrlPath, LOVELACE_PANEL } from "../../../data/panel";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import { SECTIONS_VIEW_LAYOUT } from "../views/const";
import { getViewType } from "../views/get-view-type";
import type { SuggestCardDialogParams } from "./card-editor/show-suggest-card-dialog";
@@ -144,7 +147,23 @@ export const addEntitiesToLovelaceView = async (
await saveConfig(hass!, viewUrlPath, newConfig);
} catch (_err: any) {
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
return;
}
const dashboardPath = viewUrlPath ?? LOVELACE_PANEL;
const viewPath = newConfig.views[viewIndex].path || viewIndex;
const dashboardTitle = getPanelTitleFromUrlPath(hass, dashboardPath);
showToast(element, {
message: dashboardTitle
? {
translationKey: "ui.panel.lovelace.add_entities.added",
args: { dashboard: dashboardTitle },
}
: { translationKey: "ui.panel.lovelace.add_entities.added_unnamed" },
action: {
text: { translationKey: "ui.panel.lovelace.add_entities.open" },
action: () => navigate(`/${dashboardPath}/${viewPath}`),
},
});
};
const view = config.views[viewIndex];
@@ -20,7 +20,6 @@ import {
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import type { DeviceSectionCategory } from "../../strategies/device/device-section-strategy";
import {
@@ -253,7 +252,6 @@ export class HuiDialogAddDeviceCard
addCards(this._params.lovelaceConfig, this._params.path, this._cardConfig)
);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
@@ -17,7 +17,6 @@ import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/vie
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
@@ -282,7 +281,6 @@ export class HuiDialogSuggestCard extends LitElement {
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
@@ -7,6 +7,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-svg-icon";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { computeUnusedEntities } from "../../common/compute-unused-entities";
import {
computeCards,
@@ -111,6 +112,11 @@ export class HuiUnusedEntities extends LitElement {
this._selectedEntities = ev.detail.selectedEntities;
}
private _saveConfig = async (config: LovelaceConfig): Promise<void> => {
await this.lovelace.saveConfig(config);
showSaveSuccessToast(this, this.hass);
};
private _addToLovelaceView(): void {
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
const sectionConfig = computeSection(this._selectedEntities, {});
@@ -118,7 +124,7 @@ export class HuiUnusedEntities extends LitElement {
if (this.lovelace.config.views.length === 1) {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", 0],
entities: this._selectedEntities,
cardConfig,
@@ -132,7 +138,7 @@ export class HuiUnusedEntities extends LitElement {
viewSelectedCallback: (_urlPath, _selectedDashConfig, viewIndex) => {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", viewIndex],
entities: this._selectedEntities,
cardConfig,
+8
View File
@@ -15,6 +15,8 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -48,6 +50,8 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -91,6 +97,8 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
@@ -0,0 +1,10 @@
// Install the Intl.PluralRules polyfill together with its locale data.
// FormatJS replaces Intl.PluralRules as soon as polyfill-force runs, and a
// data-less polyfill throws from select(). Keeping both imports in one module
// closes that gap for every caller, including the should-polyfill probes
// that other entry bundles run.
import "@formatjs/intl-pluralrules/polyfill-force";
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
import "@formatjs/intl-pluralrules/locale-data/en";
+10 -15
View File
@@ -14,8 +14,6 @@ import {
polyfillTimeZoneData,
} from "./locale-data-polyfill";
let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
@@ -47,10 +45,6 @@ const setDefaultTimeZone = () => {
};
const polyfillIntl = async () => {
if (polyfilled) {
return;
}
polyfilled = true;
const locale = getLocalLanguage();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillGetCanonicalLocales()) {
@@ -79,14 +73,10 @@ const polyfillIntl = async () => {
polyfills.push(import("@formatjs/intl-numberformat/polyfill-force"));
}
if (shouldPolyfillPluralRules(locale)) {
polyfills.push(
import("@formatjs/intl-pluralrules/polyfill-force").then(
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
() => import("@formatjs/intl-pluralrules/locale-data/en")
)
);
// The polyfill and its English data are one chunk, so Intl.PluralRules
// is never installed without data. Its should-polyfill probe calls
// select(), which throws on a data-less polyfill.
polyfills.push(import("./intl-pluralrules"));
}
if (shouldPolyfillRelativeTimeFormat(locale)) {
polyfills.push(import("@formatjs/intl-relativetimeformat/polyfill-force"));
@@ -100,4 +90,9 @@ const polyfillIntl = async () => {
);
};
await polyfillIntl();
// Each legacy entry bundle (core, app, authorize, onboarding) carries its own
// copy of this module, and the bundles load in parallel. A module-level flag
// only stops re-runs within one bundle, so a second bundle could probe
// Intl.* while the first was still loading locale data. Share one promise
// per page instead, so every bundle waits for the same run.
await (globalThis.__haIntlPolyfill ??= polyfillIntl());
+28 -20
View File
@@ -4502,27 +4502,32 @@
"info_text_1": "The mean type of ''{name}'' ({statistic_id}) changed from ''{metadata_mean_type}'' to ''{state_mean_type}''.",
"info_text_2": "Statistics cannot be generated until the old statistics are deleted, or the mean type matches the old statistics data again.",
"info_text_3": "Do you want to permanently delete the long term statistics of {statistic_id} from your database?"
},
"adjust_sum": {
"title": "Adjust a statistic",
"no_statistics_found": "No statistics found for this period.",
"info_text_1": "Sometimes the statistics end up being incorrect for a specific point in time. This can mess up your beautiful graphs! Select a time below to find the bad moment and adjust the data.",
"pick_a_time": "Pick a time",
"statistic": "Statistic:",
"start": "Start",
"end": "End",
"new_value": "New value",
"adjust": "Adjust",
"outliers": "Outliers",
"sum_adjusted": "Statistic sum adjusted",
"error_sum_adjusted": "Error adjusting sum: {message}",
"error_loading_statistics": "Error loading statistics: {message}"
}
},
"adjust_sum": "Adjust sum",
"clear_range": {
"title": "Delete statistics in a time range",
"info_text": "Delete the statistics of {name} between the start and end time. Use this to remove bad measurements from your graphs. Deleted statistics cannot be restored.",
"statistic": {
"more_info": "[%key:ui::panel::config::tools::tabs::states::more_info%]",
"all_data": "All data",
"show_all_data": "Show all data",
"data_range": "Recorded data",
"correct_mode": "Correction mode",
"no_selection": "No period selected",
"outliers": "Outliers",
"no_outliers": "[%key:ui::common::none%]",
"correct": "Correct data",
"correct_sum_hint": "Select the bar with the wrong value.",
"correct_mean_hint": "Select the period with wrong values, or drag across the graph to select several periods.",
"current_value": "Current value",
"period": "From {start} to {end}",
"mean": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::mean%]",
"min": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::min%]",
"max": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::max%]",
"new_value": "New value",
"adjust": "Adjust",
"adjusted": "Statistic adjusted",
"delete": "Delete period",
"delete_confirm_title": "Delete these statistics?",
"delete_confirm_text": "Deleted statistics cannot be restored.",
"delete_confirm_text_5minute": "The hourly statistics that overlap this period are deleted too. Deleted statistics cannot be restored.",
"invalid_range": "The end time must be after the start time.",
"no_statistics_found": "No statistics found for this period.",
"deleted": "Statistics deleted"
@@ -10233,7 +10238,10 @@
"add_entities": {
"yaml_unsupported": "You cannot use this function when using your dashboard in YAML mode.",
"generated_unsupported": "You can only use this function when you have taken control of your dashboard.",
"saving_failed": "Saving dashboard configuration failed."
"saving_failed": "Saving dashboard configuration failed.",
"added": "Added to {dashboard}",
"added_unnamed": "Added to the dashboard",
"open": "Open"
},
"views": {
"unnamed_view": "Unnamed view",
+2
View File
@@ -41,6 +41,8 @@ declare global {
var __HASS_URL__: string;
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
var __haNativeTimeZone: string | undefined;
// Shared by every legacy entry bundle so the Intl polyfills load once
var __haIntlPolyfill: Promise<void> | undefined;
// Handshake between the app entry and the extra modules in index.html
var __haScopedRegistryReady: boolean | undefined;
var __haLoadExtraModules: (() => void) | undefined;