Compare commits

...
Author SHA1 Message Date
Claude a353395d18 Use recorder/clear_statistics with a time range
The backend adds the optional time range to recorder/clear_statistics
instead of a separate command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014uzH9wKHRBfTGqXypaYEgV
2026-10-13 04:29:53 +00:00
Claude e648340937 Add dialog to delete statistics in a time range
A bad measurement ruins the graphs of a statistic. Mean statistics
cannot be adjusted, so the only fix was to delete the complete
statistic. A new action in the statistics table opens a dialog to pick
a time range. The dialog previews the statistics in the range and
deletes them with the recorder/clear_statistics_range command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014uzH9wKHRBfTGqXypaYEgV
2026-10-13 04:11:24 +00:00
5 changed files with 325 additions and 1 deletions

No files matched your search

+7 -1
View File
@@ -261,13 +261,19 @@ export const updateStatisticsMetadata = (
unit_class,
});
// Without a range, the statistics are removed completely.
export const clearStatistics = (
hass: Pick<HomeAssistant, "callWS">,
statistic_ids: string[]
statistic_ids: string[],
range?: { start: Date; end: Date }
) =>
hass.callWS<undefined>({
type: "recorder/clear_statistics",
statistic_ids,
...(range && {
start_time: range.start.toISOString(),
end_time: range.end.toISOString(),
}),
});
export const calculateStatisticSumGrowth = (
@@ -0,0 +1,257 @@
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;
@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 _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._start}
.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 start = this._start;
const end = this._end;
try {
const statistics = await fetchStatistics(
this._api.callWS,
start,
end,
[this.params!.statistic.statistic_id],
end.getTime() - start.getTime() > MAX_5MINUTE_PREVIEW_MS
? "hour"
: "5minute",
undefined,
this._statTypes.includes("change") ? ["change"] : ["mean", "min", "max"]
);
if (fetchId === this._fetchId) {
this._statistics = 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;
}
}
@@ -0,0 +1,21 @@
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,6 +2,7 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiDeleteClock,
mdiFormatListChecks,
mdiMenuDown,
mdiSlopeUphill,
@@ -78,6 +79,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,
@@ -305,6 +307,24 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
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"),
@@ -727,6 +747,19 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
});
}
private _showStatisticsClearRangeDialog(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { statistic: DisplayedStatisticData }
>
) {
ev.stopPropagation();
const statistic = ev.currentTarget.statistic;
showStatisticsClearRangeDialog(this, {
statistic,
name: statistic.displayName,
});
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const id = ev.detail.id;
if (id in this._states) {
+7
View File
@@ -4312,6 +4312,13 @@
}
},
"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.",
"invalid_range": "The end time must be after the start time.",
"no_statistics_found": "No statistics found for this period.",
"deleted": "Statistics deleted"
},
"data_table": {
"select_all_issues": "Select all with issues",
"name": "Name",