Compare commits

...
Author SHA1 Message Date
Paul Bottein f9ad5ed150 Merge branch 'chart-range-selection' into statistic-dialog 2026-11-06 13:56:05 +01:00
Paul Bottein 27a968d777 Keep drag to zoom when the chart select mode is point 2026-11-06 13:55:17 +01:00
Paul Bottein 3be0294867 Use select-mode and chart-select in the statistic dialog 2026-11-06 13:38:04 +01:00
Paul Bottein d96dbfaae1 Merge remote-tracking branch 'origin/chart-range-selection' into statistic-dialog
# Conflicts:
#	src/components/chart/ha-chart-base.ts
2026-11-06 13:38:04 +01:00
Paul Bottein ff3b608653 Make chart selection opt-in with select-mode and cache the mask color 2026-11-06 13:37:07 +01:00
copilot-swe-agent[bot]andpiitaya 96d15065a9 Merge remote-tracking branch 'origin/chart-range-selection' into statistic-dialog
# Conflicts:
#	gallery/src/pages/components/state-history-charts.ts
#	gallery/src/pages/components/statistics-chart.ts
#	src/components/chart/ha-chart-base.ts
#	src/components/chart/statistics-chart.ts

Co-authored-by: piitaya <[email protected]>
2026-11-05 14:06:36 +00:00
Paul Bottein ca456105e5 Clear stale chart masks after a data update 2026-11-05 14:57:44 +01:00
Paul Bottein 9f12b54a14 Move chart selection and highlight into ha-chart-base 2026-11-05 14:57:44 +01:00
Paul Bottein cf25121dda Unify chart selection with a fade mask and a clear button in the gallery 2026-11-05 14:57:44 +01:00
Paul Bottein 71f53ae3e2 Add range selection examples to the statistics chart gallery page 2026-11-05 14:57:44 +01:00
Paul Bottein 8843596e42 Keep range selection on release outside the chart and simplify the highlight 2026-11-05 14:57:44 +01:00
Paul Bottein d06ec13af6 Add range selection and highlight to charts 2026-11-05 14:57:44 +01:00
Paul Bottein 1c428c1831 Merge branch 'chart-range-selection' into statistic-dialog
# Conflicts:
#	src/panels/config/tools/statistics/dialog-statistics-adjust-sum.ts
#	src/translations/en.json
2026-11-05 10:01:26 +01:00
Paul Bottein c20a90cd81 Merge branch 'gallery-statistics-chart' into chart-range-selection 2026-11-05 10:00:24 +01:00
Paul Bottein f64a602bc8 Add history and statistics chart pages to the gallery 2026-11-05 09:59:56 +01:00
Paul Bottein 3354b5ffec Keep range selection on release outside the chart and simplify the highlight 2026-11-05 09:38:09 +01:00
Paul Bottein fce263e6ab Add range selection and highlight to charts 2026-11-05 09:38:09 +01:00
copilot-swe-agent[bot] 6317dad51b Merge remote-tracking branch 'origin/chart-range-selection' into statistic-dialog
Co-authored-by: piitaya <[email protected]>
2026-11-02 22:53:47 +00:00
Paul Bottein c350d46d44 Keep range selection on release outside the chart and simplify the highlight 2026-10-29 17:12:46 +01:00
Paul Bottein e5a43b48c2 Add range selection and highlight to charts 2026-10-29 17:12:46 +01:00
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
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
Paul Bottein bd4ff71d29 Open statistics in a dialog to view and correct their data 2026-10-29 14:57:51 +01:00
17 changed files with 1899 additions and 1147 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import type { ReactiveElement } from "lit";
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
import { uiContext } from "../../../src/data/context";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// A demo that provides its own hass hides the gallery theme from its children,
// so pass the gallery light or dark mode on to it.
export const followGalleryTheme = (
host: ReactiveElement,
hass: MockHomeAssistant
) =>
new ContextSubscriptionController(host, uiContext, (ui) => {
if (ui) {
hass.updateHass({
themes: ui.themes,
selectedTheme: ui.selectedTheme,
});
}
});
@@ -10,6 +10,7 @@ import type {
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
hass.addEntities(ENTITIES);
}
@@ -1,7 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import type {
Statistics,
@@ -11,7 +13,9 @@ import type {
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -108,6 +112,64 @@ interface Example {
height?: string;
}
interface TimeRange {
start: number;
end: number;
}
interface RangeExample {
id: string;
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "bar";
unit: string;
selectMode: ChartSelectMode;
hint: string;
}
const RANGE_EXAMPLES: RangeExample[] = [
{
id: "point",
title: "Point selection",
description:
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "point",
hint: "Click on a bar to select its hour.",
},
{
id: "range-bar",
title: "Range selection on bars",
description:
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "range",
hint: "Click or drag on the chart to select one or more bars.",
},
{
id: "range",
title: "Range selection on lines",
description: "The same on a line chart.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
selectMode: "range",
hint: "Click or drag on the chart to select a period.",
},
];
const formatRange = (range: TimeRange) =>
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _selections: Record<string, TimeRange | undefined> = {};
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
return html`${EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
</div>
</ha-card>
`
);
)}
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
}
private _renderRangeExample(example: RangeExample) {
const selection = this._selections[example.id];
return html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.highlight=${selection}
.clickForMoreInfo=${false}
data-example=${example.id}
.selectMode=${example.selectMode}
hide-legend
@chart-select=${this._rangeSelected}
></statistics-chart>
<div class="selection">
${
selection
? html`
<span>${formatRange(selection)}</span>
<ha-button
appearance="plain"
size="s"
data-example=${example.id}
@click=${this._clearSelection}
>
Clear
</ha-button>
`
: html`<span class="hint">${example.hint}</span>`
}
</div>
</div>
</ha-card>
`;
}
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = {
...this._selections,
[id]: ev.detail,
};
}
private _clearSelection(ev: Event) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = { ...this._selections, [id]: undefined };
}
static styles = css`
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
p {
margin-top: 0;
}
.selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 48px;
margin-top: var(--ha-space-2);
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
background-color: var(--secondary-background-color);
}
.hint {
color: var(--secondary-text-color);
}
`;
}
+186 -4
View File
@@ -7,7 +7,10 @@ import {
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type {
DataZoomComponentOption,
MarkAreaComponentOption,
} from "echarts/components";
import type { EChartsType } from "echarts/core";
import type {
ECElementEvent,
@@ -90,14 +93,29 @@ 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;
// The selection is shown by the mask, not by the brush
const BRUSH_STYLE = {
color: "rgba(0, 0, 0, 0)",
borderColor: "rgba(0, 0, 0, 0)",
};
export type ZoomSliderMode = "auto" | "always" | "never";
export type ChartSelectMode = "none" | "point" | "range";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
"highlight",
] as const;
const MASK_OPACITY = 0.7;
export interface ChartTimeRange {
start: number;
end: number;
}
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// "point" selects on click, "range" also selects on drag instead of zooming
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
// Everything outside this time range fades
@property({ attribute: false }) public highlight?: ChartTimeRange;
// Rounds a selection to the periods of the data, or drops it
@property({ attribute: false }) public snapSelection?: (
range: ChartTimeRange
) => ChartTimeRange | undefined;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -201,6 +231,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
private _selectingRange?: ChartTimeRange;
private _maskColor = "";
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -454,6 +490,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectMode")) {
this._updateSelectMode();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -499,6 +538,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
) {
this._disposeSonification();
}
} else if (changedProps.has("highlight")) {
this._updateMask();
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -888,6 +929,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this._maskColor =
style.getPropertyValue("--card-background-color").trim() ||
style.getPropertyValue("--primary-background-color").trim();
this.chart = echarts.init(this._chartContainer!, "custom");
this._dragToZoom = false;
@@ -970,6 +1014,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectMode !== "none") {
this._updateSelectMode();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1021,7 +1068,10 @@ 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.selectMode !== "range";
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1033,6 +1083,126 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _snapRange(range: ChartTimeRange) {
return this.snapSelection ? this.snapSelection(range) : range;
}
private _getMaskArea(): MarkAreaComponentOption | undefined {
const range = this._selectingRange ?? this.highlight;
if (!range) {
return undefined;
}
return {
silent: true,
z: 10,
itemStyle: {
color: this._maskColor,
opacity: MASK_OPACITY,
},
data: [
[{ xAxis: -Infinity }, { xAxis: range.start }],
[{ xAxis: range.end }, { xAxis: Infinity }],
],
};
}
// Only the first series carries the mask, merged in without resending data
private _updateMask() {
this.chart?.setOption({
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
});
}
private _handleSelectPointerDown = (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
};
private _handleSelectClick = (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] = this.chart.convertFromPixel("grid", point) as number[];
const selection = this._snapRange({ start: x, end: x });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _handleBrush = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
if (range) {
this._selectingRange = this._snapRange({
start: range[0],
end: range[1],
});
this._updateMask();
}
};
private _handleBrushEnd = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
this.chart?.dispatchAction({ type: "brush", areas: [] });
this._selectingRange = undefined;
this._updateMask();
const selection =
range && this._snapRange({ start: range[0], end: range[1] });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _updateSelectMode() {
if (!this.chart) {
return;
}
const zr = this.chart.getZr();
zr.off("mousedown", this._handleSelectPointerDown);
zr.off("click", this._handleSelectClick);
this.chart.off("brush", this._handleBrush);
this.chart.off("brushEnd", this._handleBrushEnd);
if (this.selectMode !== "none") {
zr.on("mousedown", this._handleSelectPointerDown);
zr.on("click", this._handleSelectClick);
}
const selectRange = this.selectMode === "range";
if (selectRange) {
this.chart.on("brush", this._handleBrush);
this.chart.on("brushEnd", this._handleBrushEnd);
this.chart.setOption({
brush: {
xAxisIndex: 0,
// Only measures the range, the mask fades what is outside
seriesIndex: [],
transformable: false,
brushStyle: BRUSH_STYLE,
},
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: selectRange ? "lineX" : false,
brushMode: "single",
brushStyle: BRUSH_STYLE,
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._dragToZoom = false;
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1475,7 +1645,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
const series = ensureArray(this.data).map((s) => {
const maskArea = this._getMaskArea();
const series = ensureArray(this.data).map((s, index) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
@@ -1513,6 +1684,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
} as HaECSeriesItem;
}
}
// ECharts merges series by id and would keep a mask left out here
if (s.type === "line" || s.type === "bar") {
result = {
...result,
markArea:
index === 0 && maskArea
? maskArea
: ((s as LineSeriesOption).markArea ?? { data: [] }),
} as HaECSeriesItem;
}
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
@@ -1762,7 +1943,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.selectMode === "range") &&
this._isZoomPress(ev)
) {
this.chart
@@ -2337,6 +2518,7 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-select": ChartTimeRange;
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
+50 -2
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,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
ChartSelectMode,
ChartTimeRange,
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: ChartTimeRange;
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.highlight=${this.highlight}
.snapSelection=${this._snapSelection}
.options=${this._chartOptions}
.height=${this.height}
.selectMode=${this.selectMode}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
// A click selects the period that contains it, a drag every period it
// touches
private _snapSelection = (
range: ChartTimeRange
): ChartTimeRange | undefined => {
let start: number | undefined;
let end: number | undefined;
Object.values(this.statisticsData ?? {}).forEach((values) =>
values.forEach((value) => {
const touched =
range.start === range.end
? value.start <= range.start && range.start < value.end
: value.start < range.end && value.end > range.start;
if (touched) {
start = Math.min(start ?? value.start, value.start);
end = Math.max(end ?? value.end, value.end);
}
})
);
return start === undefined || end === undefined
? undefined
: { start, end };
};
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
@@ -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 };
}
}
+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,
File diff suppressed because it is too large. Load diff
@@ -1,670 +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-icon-next";
import "../../../../components/ha-selector/ha-selector-datetime";
import "../../../../components/ha-selector/ha-selector-number";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
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-button
.headline=${change}
.supportingText=${formatDateTime(
new Date(stat.start),
this.hass.locale,
this.hass.config
)}
.stat=${stat}
@click=${this._setChosenStatistic}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`);
}
stats = html`<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic_values"
)}
>${rows}</ha-list-base
>`;
}
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-base {
margin: 0 -24px;
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-6);
}
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
.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(),
+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 };
+24 -20
View File
@@ -4513,28 +4513,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_values": "Statistic values",
"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"