Compare commits

..
15 changed files with 1533 additions and 1175 deletions

No files matched your search

+1 -1
View File
@@ -110,7 +110,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.2",
"marked": "18.1.1",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
+5 -5
View File
@@ -391,8 +391,8 @@ importers:
specifier: 6.16.0
version: 6.16.0
marked:
specifier: 18.1.2
version: 18.1.2
specifier: 18.1.1
version: 18.1.1
memoize-one:
specifier: 6.0.0
version: 6.0.0
@@ -5907,8 +5907,8 @@ packages:
engines: {node: '>= 18'}
hasBin: true
[email protected].2:
resolution: {integrity: sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==}
[email protected].1:
resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==}
engines: {node: '>= 20'}
hasBin: true
@@ -13689,7 +13689,7 @@ snapshots:
[email protected]: {}
[email protected].2: {}
[email protected].1: {}
[email protected]: {}
+11 -21
View File
@@ -16,7 +16,6 @@ import {
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
@@ -32,8 +31,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-list";
import "../ha-list-item";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,19 +165,11 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<ha-list-base>
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-list-item-button
.range=${range}
.index=${index}
@click=${this._setDateRange}
>
<span slot="headline">${name}</span>
</ha-list-item-button>
`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list-base>
</ha-list>
`;
}
@@ -351,12 +342,11 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
private _setDateRange(ev: HASSDomEvent<{ index: number }>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
@@ -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,
+16 -16
View File
@@ -5,10 +5,9 @@ import { customElement, property, state } from "lit/decorators";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/user/ha-person-badge";
import type { Person } from "../../../data/person";
import {
@@ -93,22 +92,23 @@ export class HaConfigPerson extends LitElement {
</span>
<ha-card outlined class="storage">
<ha-list-base>
<ha-list>
${this._storageItems.map(
(entry) => html`
<ha-list-item-button
<ha-list-item
graphic="avatar"
@click=${this._openEditEntry}
.entry=${entry}
>
<ha-person-badge
.person=${entry}
slot="start"
slot="graphic"
></ha-person-badge>
<span slot="headline">${entry.name}</span>
</ha-list-item-button>
<span>${entry.name}</span>
</ha-list-item>
`
)}
</ha-list-base>
</ha-list>
${
this._storageItems.length === 0
? html`
@@ -138,19 +138,19 @@ export class HaConfigPerson extends LitElement {
this._configItems.length > 0
? html`
<ha-card outlined header="Configuration.yaml persons">
<ha-list-base>
<ha-list>
${this._configItems.map(
(entry) => html`
<ha-list-item-base>
<ha-list-item graphic="avatar">
<ha-person-badge
.person=${entry}
slot="start"
slot="graphic"
></ha-person-badge>
<span slot="headline">${entry.name}</span>
</ha-list-item-base>
<span>${entry.name}</span>
</ha-list-item>
`
)}
</ha-list-base>
</ha-list>
</ha-card>
`
: nothing
@@ -299,7 +299,7 @@ export class HaConfigPerson extends LitElement {
align-items: center;
justify-content: space-around;
}
ha-list-base:has(+ .empty) {
ha-list:has(+ .empty) {
display: none;
}
`;
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(),
+24 -19
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"