From 4c8e863c0e2997b318415d05a2976f40336fb156 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Oct 2022 14:24:24 +0200 Subject: [PATCH 1/9] Use firstday of the week for range shortcuts in date pickers (#14210) --- src/common/datetime/first_weekday.ts | 10 +++++++--- src/panels/history/ha-panel-history.ts | 6 ++++-- src/panels/logbook/ha-panel-logbook.ts | 6 ++++-- .../components/hui-energy-period-selector.ts | 13 ++++++++++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/common/datetime/first_weekday.ts b/src/common/datetime/first_weekday.ts index 48b7b75261..3c34eb7c14 100644 --- a/src/common/datetime/first_weekday.ts +++ b/src/common/datetime/first_weekday.ts @@ -11,16 +11,20 @@ export const weekdays = [ "saturday", ] as const; -export const firstWeekdayIndex = (locale: FrontendLocaleData): number => { +type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { if (locale.first_weekday === FirstWeekday.language) { // @ts-ignore if ("weekInfo" in Intl.Locale.prototype) { // @ts-ignore return new Intl.Locale(locale.language).weekInfo.firstDay % 7; } - return getWeekStartByLocale(locale.language) % 7; + return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex; } - return weekdays.indexOf(locale.first_weekday); + return weekdays.includes(locale.first_weekday) + ? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex) + : 1; }; export const firstWeekday = (locale: FrontendLocaleData) => { diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 8be17099f5..95573534bd 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -16,6 +16,7 @@ import { } from "home-assistant-js-websocket/dist/types"; import { css, html, LitElement, PropertyValues } from "lit"; import { property, state } from "lit/decorators"; +import { firstWeekdayIndex } from "../../common/datetime/first_weekday"; import { LocalStorage } from "../../common/decorators/local-storage"; import { ensureArray } from "../../common/ensure-array"; import { navigate } from "../../common/navigate"; @@ -179,8 +180,9 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { } const today = new Date(); - const weekStart = startOfWeek(today); - const weekEnd = endOfWeek(today); + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + const weekStart = startOfWeek(today, { weekStartsOn }); + const weekEnd = endOfWeek(today, { weekStartsOn }); this._ranges = { [this.hass.localize("ui.components.date-range-picker.ranges.today")]: [ diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 84f330bb94..8f53ce7f56 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -12,6 +12,7 @@ import { } from "date-fns/esm"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { firstWeekdayIndex } from "../../common/datetime/first_weekday"; import { navigate } from "../../common/navigate"; import { createSearchParam, @@ -108,8 +109,9 @@ export class HaPanelLogbook extends LitElement { } const today = new Date(); - const weekStart = startOfWeek(today); - const weekEnd = endOfWeek(today); + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + const weekStart = startOfWeek(today, { weekStartsOn }); + const weekEnd = endOfWeek(today, { weekStartsOn }); this._ranges = { [this.hass.localize("ui.components.date-range-picker.ranges.today")]: [ diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index 4a943b9ab8..b1fb786dba 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -36,6 +36,7 @@ import { EnergyData, getEnergyDataCollection } from "../../../data/energy"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, ToggleButton } from "../../../types"; import { computeRTLDirection } from "../../../common/util/compute_rtl"; +import { firstWeekdayIndex } from "../../../common/datetime/first_weekday"; @customElement("hui-energy-period-selector") export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @@ -179,11 +180,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { ? today : this._startDate; + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + this._setDate( this._period === "day" ? startOfDay(start) : this._period === "week" - ? startOfWeek(start, { weekStartsOn: 1 }) + ? startOfWeek(start, { weekStartsOn }) : this._period === "month" ? startOfMonth(start) : startOfYear(start) @@ -191,11 +194,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _pickToday() { + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + this._setDate( this._period === "day" ? startOfToday() : this._period === "week" - ? startOfWeek(new Date(), { weekStartsOn: 1 }) + ? startOfWeek(new Date(), { weekStartsOn }) : this._period === "month" ? startOfMonth(new Date()) : startOfYear(new Date()) @@ -227,11 +232,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _setDate(startDate: Date) { + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + const endDate = this._period === "day" ? endOfDay(startDate) : this._period === "week" - ? endOfWeek(startDate, { weekStartsOn: 1 }) + ? endOfWeek(startDate, { weekStartsOn }) : this._period === "month" ? endOfMonth(startDate) : endOfYear(startDate); From b3e2beac5af5efb779e9da8d0c5bcb1026248815 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Oct 2022 15:29:22 +0200 Subject: [PATCH 2/9] don't use entity name as label in more info stats (#14213) --- src/components/chart/statistics-chart.ts | 14 +++++++++----- src/dialogs/more-info/ha-more-info-history.ts | 4 ++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 8a597fd56a..b1f3be804d 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -253,7 +253,7 @@ class StatisticsChart extends LitElement { const firstStat = stats[0]; const meta = statisticsMetaData?.[firstStat.statistic_id]; let name = names[firstStat.statistic_id]; - if (!name) { + if (name === undefined) { name = getStatisticLabel(this.hass, firstStat.statistic_id, meta); } @@ -324,10 +324,14 @@ class StatisticsChart extends LitElement { const band = drawBands && (type === "min" || type === "max"); statTypes.push(type); statDataSets.push({ - label: `${name} (${this.hass.localize( - `ui.components.statistics_charts.statistic_types.${type}` - )}) - `, + label: name + ? `${name} (${this.hass.localize( + `ui.components.statistics_charts.statistic_types.${type}` + )}) + ` + : this.hass.localize( + `ui.components.statistics_charts.statistic_types.${type}` + ), fill: drawBands ? type === "min" ? "+1" diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 43eaa9fd84..679d0914a1 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -36,6 +36,8 @@ export class MoreInfoHistory extends LitElement { private _showMoreHref = ""; + private _statNames?: Record; + private _throttleGetStateHistory = throttle(() => { this._getStateHistory(); }, 10000); @@ -62,6 +64,7 @@ export class MoreInfoHistory extends LitElement { .isLoadingData=${!this._statistics} .statisticsData=${this._statistics} .statTypes=${statTypes} + .names=${this._statNames} >` : html` Date: Thu, 27 Oct 2022 15:29:47 +0200 Subject: [PATCH 3/9] Fix min height on lovelace panel for ios (#14207) --- src/panels/lovelace/hui-root.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 05e5187259..e574a842f1 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -1007,7 +1007,10 @@ class HUIRoot extends LitElement { color: var(--error-color); } #view { - min-height: calc(100vh - var(--header-height)); + min-height: calc( + 100vh - var(--header-height) - env(safe-area-inset-top) - + env(safe-area-inset-bottom) + ); /** * Since we only set min-height, if child nodes need percentage * heights they must use absolute positioning so we need relative @@ -1022,7 +1025,10 @@ class HUIRoot extends LitElement { * In edit mode we have the tab bar on a new line * */ .edit-mode #view { - min-height: calc(100vh - var(--header-height) - 48px); + min-height: calc( + 100vh - var(--header-height) - 48px - env(safe-area-inset-top) - + env(safe-area-inset-bottom) + ); } #view > * { /** From a56b2e3270a55d052e226138499a1fb25ca26028 Mon Sep 17 00:00:00 2001 From: Steve Repsher Date: Thu, 27 Oct 2022 09:30:08 -0400 Subject: [PATCH 4/9] Rename autofill to autocomplete for text selector (#14202) --- src/components/ha-selector/ha-selector-text.ts | 4 ++-- src/data/selector.ts | 2 +- src/onboarding/onboarding-create-user.ts | 12 ++++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 003775832d..1f5190dabe 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -39,7 +39,7 @@ export class HaTextSelector extends LitElement { .disabled=${this.disabled} @input=${this._handleChange} autocapitalize="none" - .autocomplete=${this.selector.text.autofill} + .autocomplete=${this.selector.text.autocomplete} spellcheck="false" .required=${this.required} autogrow @@ -59,7 +59,7 @@ export class HaTextSelector extends LitElement { html`
` : this.selector.text?.suffix} .required=${this.required} - .autocomplete=${this.selector.text.autofill} + .autocomplete=${this.selector.text.autocomplete} > ${this.selector.text?.type === "password" ? html` Date: Thu, 27 Oct 2022 19:17:42 +0200 Subject: [PATCH 5/9] Allow null selector (#14212) --- .../ha-form/compute-initial-ha-form-data.ts | 12 ++-- .../ha-selector/ha-selector-area.ts | 14 ++-- .../ha-selector/ha-selector-attribute.ts | 6 +- .../ha-selector/ha-selector-config-entry.ts | 2 +- .../ha-selector/ha-selector-device.ts | 13 ++-- .../ha-selector/ha-selector-duration.ts | 2 +- .../ha-selector/ha-selector-entity.ts | 20 ++++-- .../ha-selector/ha-selector-file.ts | 2 +- .../ha-selector/ha-selector-icon.ts | 4 +- .../ha-selector/ha-selector-location.ts | 6 +- .../ha-selector/ha-selector-number.ts | 30 ++++---- .../ha-selector/ha-selector-select.ts | 26 +++---- .../ha-selector/ha-selector-state.ts | 4 +- .../ha-selector/ha-selector-target.ts | 12 ++-- .../ha-selector/ha-selector-text.ts | 4 +- .../ha-selector/ha-selector-ui-action.ts | 2 +- src/data/selector.ts | 68 +++++++++---------- 17 files changed, 121 insertions(+), 106 deletions(-) diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 96c1ae58fb..ebcec7414d 100644 --- a/src/components/ha-form/compute-initial-ha-form-data.ts +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -39,11 +39,11 @@ export const computeInitialHaFormData = ( const selector: Selector = field.selector; if ("device" in selector) { - data[field.name] = selector.device.multiple ? [] : ""; + data[field.name] = selector.device?.multiple ? [] : ""; } else if ("entity" in selector) { - data[field.name] = selector.entity.multiple ? [] : ""; + data[field.name] = selector.entity?.multiple ? [] : ""; } else if ("area" in selector) { - data[field.name] = selector.area.multiple ? [] : ""; + data[field.name] = selector.area?.multiple ? [] : ""; } else if ("boolean" in selector) { data[field.name] = false; } else if ( @@ -56,9 +56,9 @@ export const computeInitialHaFormData = ( ) { data[field.name] = ""; } else if ("number" in selector) { - data[field.name] = selector.number.min ?? 0; + data[field.name] = selector.number?.min ?? 0; } else if ("select" in selector) { - if (selector.select.options.length) { + if (selector.select?.options.length) { data[field.name] = selector.select.options[0][0]; } } else if ("duration" in selector) { @@ -75,7 +75,7 @@ export const computeInitialHaFormData = ( } else if ("color_rgb" in selector) { data[field.name] = [0, 0, 0]; } else if ("color_temp" in selector) { - data[field.name] = selector.color_temp.min_mireds ?? 153; + data[field.name] = selector.color_temp?.min_mireds ?? 153; } else if ( "action" in selector || "media" in selector || diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index c6f1be6efd..704a36ed2c 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -55,8 +55,8 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { protected updated(changedProperties: PropertyValues): void { if ( changedProperties.has("selector") && - (this.selector.area.device?.integration || - this.selector.area.entity?.integration) && + (this.selector.area?.device?.integration || + this.selector.area?.entity?.integration) && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { @@ -67,14 +67,14 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { protected render(): TemplateResult { if ( - (this.selector.area.device?.integration || - this.selector.area.entity?.integration) && + (this.selector.area?.device?.integration || + this.selector.area?.entity?.integration) && !this._entitySources ) { return html``; } - if (!this.selector.area.multiple) { + if (!this.selector.area?.multiple) { return html` { - if (!this.selector.area.entity) { + if (!this.selector.area?.entity) { return true; } @@ -118,7 +118,7 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { }; private _filterDevices = (device: DeviceRegistryEntry): boolean => { - if (!this.selector.area.device) { + if (!this.selector.area?.device) { return true; } diff --git a/src/components/ha-selector/ha-selector-attribute.ts b/src/components/ha-selector/ha-selector-attribute.ts index da06508e2f..da194bdf22 100644 --- a/src/components/ha-selector/ha-selector-attribute.ts +++ b/src/components/ha-selector/ha-selector-attribute.ts @@ -30,9 +30,9 @@ export class HaSelectorAttribute extends SubscribeMixin(LitElement) { return html` `; } diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index bfb0928524..e56808b766 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -53,7 +53,7 @@ export class HaDeviceSelector extends SubscribeMixin(LitElement) { super.updated(changedProperties); if ( changedProperties.has("selector") && - this.selector.device.integration && + this.selector.device?.integration && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { @@ -63,11 +63,11 @@ export class HaDeviceSelector extends SubscribeMixin(LitElement) { } protected render() { - if (this.selector.device.integration && !this._entitySources) { + if (this.selector.device?.integration && !this._entitySources) { return html``; } - if (!this.selector.device.multiple) { + if (!this.selector.device?.multiple) { return html` `; } diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index 2c7062171c..b56ecf1723 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -30,14 +30,14 @@ export class HaEntitySelector extends LitElement { @property({ type: Boolean }) public required = true; protected render() { - if (!this.selector.entity.multiple) { + if (!this.selector.entity?.multiple) { return html` { @@ -73,8 +73,16 @@ export class HaEntitySelector extends LitElement { } } - private _filterEntities = (entity: HassEntity): boolean => - filterSelectorEntities(this.selector.entity, entity, this._entitySources); + private _filterEntities = (entity: HassEntity): boolean => { + if (!this.selector?.entity) { + return true; + } + return filterSelectorEntities( + this.selector.entity, + entity, + this._entitySources + ); + }; } declare global { diff --git a/src/components/ha-selector/ha-selector-file.ts b/src/components/ha-selector/ha-selector-file.ts index 589e43cf9d..f46ca12696 100644 --- a/src/components/ha-selector/ha-selector-file.ts +++ b/src/components/ha-selector/ha-selector-file.ts @@ -32,7 +32,7 @@ export class HaFileSelector extends LitElement { return html` `; diff --git a/src/components/ha-selector/ha-selector-location.ts b/src/components/ha-selector/ha-selector-location.ts index 05a2b3b451..b4b0c6524b 100644 --- a/src/components/ha-selector/ha-selector-location.ts +++ b/src/components/ha-selector/ha-selector-location.ts @@ -43,7 +43,7 @@ export class HaLocationSelector extends LitElement { value?: LocationSelectorValue ): MarkerLocation[] => { const computedStyles = getComputedStyle(this); - const zoneRadiusColor = selector.location.radius + const zoneRadiusColor = selector.location?.radius ? computedStyles.getPropertyValue("--zone-radius-color") || computedStyles.getPropertyValue("--accent-color") : undefined; @@ -52,10 +52,10 @@ export class HaLocationSelector extends LitElement { id: "location", latitude: value?.latitude || this.hass.config.latitude, longitude: value?.longitude || this.hass.config.longitude, - radius: selector.location.radius ? value?.radius || 1000 : undefined, + radius: selector.location?.radius ? value?.radius || 1000 : undefined, radius_color: zoneRadiusColor, icon: - selector.location.icon || selector.location.radius + selector.location?.icon || selector.location?.radius ? "mdi:map-marker-radius" : "mdi:map-marker", location_editable: true, diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 343490af31..4edf7e2eb2 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -27,7 +27,7 @@ export class HaNumberSelector extends LitElement { @property({ type: Boolean }) public disabled = false; protected render() { - const isBox = this.selector.number.mode === "box"; + const isBox = this.selector.number?.mode === "box"; return html`
@@ -37,10 +37,10 @@ export class HaNumberSelector extends LitElement { ? html`${this.label}${this.required ? " *" : ""}` : ""} @@ -80,7 +82,7 @@ export class HaNumberSelector extends LitElement { } private get _value() { - return this.value ?? (this.selector.number.min || 0); + return this.value ?? (this.selector.number?.min || 0); } private _handleInputChange(ev) { @@ -88,7 +90,7 @@ export class HaNumberSelector extends LitElement { const value = ev.target.value === "" || isNaN(ev.target.value) ? this.required - ? this.selector.number.min || 0 + ? this.selector.number?.min || 0 : undefined : Number(ev.target.value); if (this.value === value) { diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index c454d339cd..fddb300c7b 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -9,6 +9,7 @@ import type { HomeAssistant } from "../../types"; import "../ha-checkbox"; import "../ha-chip"; import "../ha-chip-set"; +import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; import "../ha-formfield"; import "../ha-radio"; @@ -36,12 +37,13 @@ export class HaSelectSelector extends LitElement { private _filter = ""; protected render() { - const options = this.selector.select.options.map((option) => - typeof option === "object" ? option : { value: option, label: option } - ); + const options = + this.selector.select?.options.map((option) => + typeof option === "object" ? option : { value: option, label: option } + ) || []; - if (!this.selector.select.custom_value && this._mode === "list") { - if (!this.selector.select.multiple) { + if (!this.selector.select?.custom_value && this._mode === "list") { + if (!this.selector.select?.multiple) { return html`
${this.label} @@ -82,7 +84,7 @@ export class HaSelectSelector extends LitElement { `; } - if (this.selector.select.multiple) { + if (this.selector.select?.multiple) { const value = !this.value || this.value === "" ? [] : (this.value as string[]); @@ -123,7 +125,7 @@ export class HaSelectSelector extends LitElement { `; } - if (this.selector.select.custom_value) { + if (this.selector.select?.custom_value) { if ( this.value !== undefined && !options.find((option) => option.value === this.value) @@ -178,8 +180,8 @@ export class HaSelectSelector extends LitElement { private get _mode(): "list" | "dropdown" { return ( - this.selector.select.mode || - (this.selector.select.options.length < 6 ? "list" : "dropdown") + this.selector.select?.mode || + ((this.selector.select?.options?.length || 0) < 6 ? "list" : "dropdown") ); } @@ -243,7 +245,7 @@ export class HaSelectSelector extends LitElement { return; } - if (!this.selector.select.multiple) { + if (!this.selector.select?.multiple) { fireEvent(this, "value-changed", { value: newValue, }); @@ -271,14 +273,14 @@ export class HaSelectSelector extends LitElement { this._filter = ev?.detail.value || ""; const filteredItems = this.comboBox.items?.filter((item) => { - if (this.selector.select.multiple && this.value?.includes(item.value)) { + if (this.selector.select?.multiple && this.value?.includes(item.value)) { return false; } const label = item.label || item.value; return label.toLowerCase().includes(this._filter?.toLowerCase()); }); - if (this._filter && this.selector.select.custom_value) { + if (this._filter && this.selector.select?.custom_value) { filteredItems?.unshift({ label: this._filter, value: this._filter }); } diff --git a/src/components/ha-selector/ha-selector-state.ts b/src/components/ha-selector/ha-selector-state.ts index b72b0349bd..a28628ed86 100644 --- a/src/components/ha-selector/ha-selector-state.ts +++ b/src/components/ha-selector/ha-selector-state.ts @@ -30,9 +30,9 @@ export class HaSelectorState extends SubscribeMixin(LitElement) { return html` { @@ -76,8 +76,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { protected render(): TemplateResult { if ( - (this.selector.target.device?.integration || - this.selector.target.entity?.integration) && + (this.selector.target?.device?.integration || + this.selector.target?.entity?.integration) && !this._entitySources ) { return html``; @@ -94,7 +94,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { } private _filterEntities = (entity: HassEntity): boolean => { - if (!this.selector.target.entity) { + if (!this.selector.target?.entity) { return true; } @@ -106,7 +106,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { }; private _filterDevices = (device: DeviceRegistryEntry): boolean => { - if (!this.selector.target.device) { + if (!this.selector.target?.device) { return true; } diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 1f5190dabe..a9d3de47d4 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -39,7 +39,7 @@ export class HaTextSelector extends LitElement { .disabled=${this.disabled} @input=${this._handleChange} autocapitalize="none" - .autocomplete=${this.selector.text.autocomplete} + .autocomplete=${this.selector.text?.autocomplete} spellcheck="false" .required=${this.required} autogrow @@ -59,7 +59,7 @@ export class HaTextSelector extends LitElement { html`
` : this.selector.text?.suffix} .required=${this.required} - .autocomplete=${this.selector.text.autocomplete} + .autocomplete=${this.selector.text?.autocomplete} > ${this.selector.text?.type === "password" ? html` diff --git a/src/data/selector.ts b/src/data/selector.ts index c86922162d..061b246695 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -36,26 +36,26 @@ export type Selector = export interface ActionSelector { // eslint-disable-next-line @typescript-eslint/ban-types - action: {}; + action: {} | null; } export interface AddonSelector { addon: { name?: string; slug?: string; - }; + } | null; } export interface SelectorDevice { - integration?: DeviceSelector["device"]["integration"]; - manufacturer?: DeviceSelector["device"]["manufacturer"]; - model?: DeviceSelector["device"]["model"]; + integration?: NonNullable["integration"]; + manufacturer?: NonNullable["manufacturer"]; + model?: NonNullable["model"]; } export interface SelectorEntity { - integration?: EntitySelector["entity"]["integration"]; - domain?: EntitySelector["entity"]["domain"]; - device_class?: EntitySelector["entity"]["device_class"]; + integration?: NonNullable["integration"]; + domain?: NonNullable["domain"]; + device_class?: NonNullable["device_class"]; } export interface AreaSelector { @@ -63,47 +63,47 @@ export interface AreaSelector { entity?: SelectorEntity; device?: SelectorDevice; multiple?: boolean; - }; + } | null; } export interface AttributeSelector { attribute: { entity_id?: string; hide_attributes?: readonly string[]; - }; + } | null; } export interface BooleanSelector { // eslint-disable-next-line @typescript-eslint/ban-types - boolean: {}; + boolean: {} | null; } export interface ColorRGBSelector { // eslint-disable-next-line @typescript-eslint/ban-types - color_rgb: {}; + color_rgb: {} | null; } export interface ColorTempSelector { color_temp: { min_mireds?: number; max_mireds?: number; - }; + } | null; } export interface ConfigEntrySelector { config_entry: { integration?: string; - }; + } | null; } export interface DateSelector { // eslint-disable-next-line @typescript-eslint/ban-types - date: {}; + date: {} | null; } export interface DateTimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - datetime: {}; + datetime: {} | null; } export interface DeviceSelector { @@ -113,13 +113,13 @@ export interface DeviceSelector { model?: string; entity?: SelectorEntity; multiple?: boolean; - }; + } | null; } export interface DurationSelector { duration: { enable_day?: boolean; - }; + } | null; } export interface EntitySelector { @@ -130,24 +130,24 @@ export interface EntitySelector { multiple?: boolean; include_entities?: string[]; exclude_entities?: string[]; - }; + } | null; } export interface FileSelector { file: { accept: string; - }; + } | null; } export interface IconSelector { icon: { placeholder?: string; fallbackPath?: string; - }; + } | null; } export interface LocationSelector { - location: { radius?: boolean; icon?: string }; + location: { radius?: boolean; icon?: string } | null; } export interface LocationSelectorValue { @@ -158,7 +158,7 @@ export interface LocationSelectorValue { export interface MediaSelector { // eslint-disable-next-line @typescript-eslint/ban-types - media: {}; + media: {} | null; } export interface MediaSelectorValue { @@ -176,7 +176,7 @@ export interface MediaSelectorValue { export interface NavigationSelector { // eslint-disable-next-line @typescript-eslint/ban-types - navigation: {}; + navigation: {} | null; } export interface NumberSelector { @@ -186,12 +186,12 @@ export interface NumberSelector { step?: number; mode?: "box" | "slider"; unit_of_measurement?: string; - }; + } | null; } export interface ObjectSelector { // eslint-disable-next-line @typescript-eslint/ban-types - object: {}; + object: {} | null; } export interface SelectOption { @@ -206,14 +206,14 @@ export interface SelectSelector { custom_value?: boolean; mode?: "list" | "dropdown"; options: readonly string[] | readonly SelectOption[]; - }; + } | null; } export interface StateSelector { state: { entity_id?: string; attribute?: string; - }; + } | null; } export interface StringSelector { @@ -235,34 +235,34 @@ export interface StringSelector { | "color"; suffix?: string; autocomplete?: string; - }; + } | null; } export interface TargetSelector { target: { entity?: SelectorEntity; device?: SelectorDevice; - }; + } | null; } export interface TemplateSelector { // eslint-disable-next-line @typescript-eslint/ban-types - template: {}; + template: {} | null; } export interface ThemeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - theme: {}; + theme: {} | null; } export interface TimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - time: {}; + time: {} | null; } export interface UiActionSelector { "ui-action": { actions?: UiAction[]; - }; + } | null; } export const filterSelectorDevices = ( From 6326bb010fbc7af03662a486823fa8849720531f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Oct 2022 20:55:40 +0200 Subject: [PATCH 6/9] Only show existing weekdays (#14219) --- src/panels/profile/ha-pick-first-weekday-row.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/panels/profile/ha-pick-first-weekday-row.ts b/src/panels/profile/ha-pick-first-weekday-row.ts index 08b09af9a9..c9142efb57 100644 --- a/src/panels/profile/ha-pick-first-weekday-row.ts +++ b/src/panels/profile/ha-pick-first-weekday-row.ts @@ -31,7 +31,12 @@ class FirstWeekdayRow extends LitElement { .value=${this.hass.locale.first_weekday} @selected=${this._handleFormatSelection} > - ${Object.values(FirstWeekday).map((day) => { + ${[ + FirstWeekday.language, + FirstWeekday.monday, + FirstWeekday.saturday, + FirstWeekday.sunday, + ].map((day) => { const value = this.hass.localize( `ui.panel.profile.first_weekday.values.${day}` ); From 9d730919d56de31a8a364c64701dfc398b5bece3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Oct 2022 21:59:55 +0200 Subject: [PATCH 7/9] Add statistic card (#14198) * Add statistic card * Update selector.ts * Update translations * review --- .../ha-selector/ha-selector-statistic.ts | 53 +++ src/components/ha-selector/ha-selector.ts | 1 + src/data/recorder.ts | 38 +++ src/data/selector.ts | 10 +- .../lovelace/cards/hui-statistic-card.ts | 316 ++++++++++++++++++ src/panels/lovelace/cards/types.ts | 15 +- .../create-element/create-card-element.ts | 1 + .../hui-statistic-card-editor.ts | 266 +++++++++++++++ src/panels/lovelace/editor/lovelace-cards.ts | 4 + src/translations/en.json | 22 ++ 10 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 src/components/ha-selector/ha-selector-statistic.ts create mode 100644 src/panels/lovelace/cards/hui-statistic-card.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts diff --git a/src/components/ha-selector/ha-selector-statistic.ts b/src/components/ha-selector/ha-selector-statistic.ts new file mode 100644 index 0000000000..360075069a --- /dev/null +++ b/src/components/ha-selector/ha-selector-statistic.ts @@ -0,0 +1,53 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { StatisticSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../entity/ha-statistics-picker"; + +@customElement("ha-selector-statistic") +export class HaStatisticSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: StatisticSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + if (!this.selector.statistic.multiple) { + return html``; + } + + return html` + ${this.label ? html`` : ""} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-statistic": HaStatisticSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index e77a3e7847..09125e10a7 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -17,6 +17,7 @@ const LOAD_ELEMENTS = { device: () => import("./ha-selector-device"), duration: () => import("./ha-selector-duration"), entity: () => import("./ha-selector-entity"), + statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), navigation: () => import("./ha-selector-navigation"), number: () => import("./ha-selector-number"), diff --git a/src/data/recorder.ts b/src/data/recorder.ts index e711cd4670..2d8115cdb3 100644 --- a/src/data/recorder.ts +++ b/src/data/recorder.ts @@ -1,4 +1,5 @@ import { computeStateName } from "../common/entity/compute_state_name"; +import { HaDurationData } from "../components/ha-duration-input"; import { HomeAssistant } from "../types"; export type StatisticType = "state" | "sum" | "min" | "max" | "mean"; @@ -19,6 +20,13 @@ export interface StatisticValue { state: number | null; } +export interface Statistic { + max: number | null; + mean: number | null; + min: number | null; + change: number | null; +} + export interface StatisticsMetaData { statistics_unit_of_measurement: string | null; statistic_id: string; @@ -122,6 +130,36 @@ export const fetchStatistics = ( units, }); +export const fetchStatistic = ( + hass: HomeAssistant, + statistic_id: string, + period: { + fixed_period?: { start: string | Date; end: string | Date }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }, + units?: StatisticsUnitConfiguration +) => + hass.callWS({ + type: "recorder/statistic_during_period", + statistic_id, + units, + fixed_period: period.fixed_period + ? { + start_time: + period.fixed_period.start instanceof Date + ? period.fixed_period.start.toISOString() + : period.fixed_period.start, + end_time: + period.fixed_period.end instanceof Date + ? period.fixed_period.end.toISOString() + : period.fixed_period.end, + } + : undefined, + calendar: period.calendar, + rolling_window: period.rolling_window, + }); + export const validateStatistics = (hass: HomeAssistant) => hass.callWS({ type: "recorder/validate_statistics", diff --git a/src/data/selector.ts b/src/data/selector.ts index 061b246695..a50128c979 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -27,6 +27,7 @@ export type Selector = | ObjectSelector | SelectSelector | StateSelector + | StatisticSelector | StringSelector | TargetSelector | TemplateSelector @@ -133,6 +134,13 @@ export interface EntitySelector { } | null; } +export interface StatisticSelector { + statistic: { + device_class?: string; + multiple?: boolean; + }; +} + export interface FileSelector { file: { accept: string; @@ -195,7 +203,7 @@ export interface ObjectSelector { } export interface SelectOption { - value: string; + value: any; label: string; disabled?: boolean; } diff --git a/src/panels/lovelace/cards/hui-statistic-card.ts b/src/panels/lovelace/cards/hui-statistic-card.ts new file mode 100644 index 0000000000..61c286062b --- /dev/null +++ b/src/panels/lovelace/cards/hui-statistic-card.ts @@ -0,0 +1,316 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { isValidEntityId } from "../../../common/entity/valid_entity_id"; +import { formatNumber } from "../../../common/number/format_number"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-state-icon"; +import { + fetchStatistic, + getDisplayUnit, + getStatisticLabel, + getStatisticMetadata, + isExternalStatistic, + StatisticsMetaData, +} from "../../../data/recorder"; +import { HomeAssistant } from "../../../types"; +import { computeCardSize } from "../common/compute-card-size"; +import { findEntities } from "../common/find-entities"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import { createHeaderFooterElement } from "../create-element/create-header-footer-element"; +import { + LovelaceCard, + LovelaceCardEditor, + LovelaceHeaderFooter, +} from "../types"; +import { HuiErrorCard } from "./hui-error-card"; +import { EntityCardConfig, StatisticCardConfig } from "./types"; + +@customElement("hui-statistic-card") +export class HuiStatisticCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-statistic-card-editor"); + return document.createElement("hui-statistic-card-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFill: string[] + ) { + const includeDomains = ["sensor"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFill, + includeDomains, + (stateObj: HassEntity) => "state_class" in stateObj.attributes + ); + + return { + entity: foundEntities[0] || "", + period: { calendar: { period: "month", offset: 0 } }, + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _value?: number | null; + + @state() private _metadata?: StatisticsMetaData; + + @state() private _error?: string; + + private _interval?: number; + + private _footerElement?: HuiErrorCard | LovelaceHeaderFooter; + + public disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this._interval); + } + + public setConfig(config: StatisticCardConfig): void { + if (!config.entity) { + throw new Error("Entity must be specified"); + } + if (!config.stat_type) { + throw new Error("Statistic type must be specified"); + } + if (!config.period) { + throw new Error("Period must be specified"); + } + if ( + config.entity && + !isExternalStatistic(config.entity) && + !isValidEntityId(config.entity) + ) { + throw new Error("Invalid entity"); + } + + this._config = config; + this._error = undefined; + this._fetchStatistic(); + this._fetchMetadata(); + + if (this._config.footer) { + this._footerElement = createHeaderFooterElement(this._config.footer); + } else if (this._footerElement) { + this._footerElement = undefined; + } + } + + public async getCardSize(): Promise { + let size = 2; + if (this._footerElement) { + const footerSize = computeCardSize(this._footerElement); + size += footerSize instanceof Promise ? await footerSize : footerSize; + } + return size; + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + if (this._error) { + return html` ${this._error} `; + } + + const stateObj = this.hass.states[this._config.entity]; + const name = + this._config.name || + getStatisticLabel(this.hass, this._config.entity, this._metadata); + + return html` + +
+
${name}
+
+ +
+
+
+ ${this._value === undefined + ? "" + : this._value === null + ? "?" + : formatNumber(this._value, this.hass.locale)} + ${this._config.unit || + getDisplayUnit( + this.hass, + this._config.entity, + this._metadata + )} +
+ ${this._footerElement} +
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + // Side Effect used to update footer hass while keeping optimizations + if (this._footerElement) { + this._footerElement.hass = this.hass; + } + if ( + changedProps.has("_value") || + changedProps.has("_metadata") || + changedProps.has("_error") + ) { + return true; + } + if (this._config) { + return hasConfigOrEntityChanged(this, changedProps); + } + return true; + } + + protected firstUpdated() { + this._fetchStatistic(); + this._fetchMetadata(); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!this._config || !this.hass) { + return; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const oldConfig = changedProps.get("_config") as + | EntityCardConfig + | undefined; + + if ( + !oldHass || + !oldConfig || + oldHass.themes !== this.hass.themes || + oldConfig.theme !== this._config.theme + ) { + applyThemesOnElement(this, this.hass.themes, this._config!.theme); + } + } + + private async _fetchStatistic() { + if (!this.hass || !this._config) { + return; + } + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._fetchStatistic(), + 5 * 1000 * 60 + ); + try { + const stats = await fetchStatistic( + this.hass, + this._config.entity, + this._config.period + ); + this._value = stats[this._config!.stat_type]; + this._error = undefined; + } catch (e: any) { + this._error = e.message; + } + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + try { + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )?.[0]; + } catch (e: any) { + this._error = e.message; + } + } + + private _handleClick(): void { + fireEvent(this, "hass-more-info", { entityId: this._config!.entity }); + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-card { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + cursor: pointer; + outline: none; + } + + .header { + display: flex; + padding: 8px 16px 0; + justify-content: space-between; + } + + .name { + color: var(--secondary-text-color); + line-height: 40px; + font-weight: 500; + font-size: 16px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .icon { + color: var(--state-icon-color, #44739e); + line-height: 40px; + } + + .info { + padding: 0px 16px 16px; + margin-top: -4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 28px; + } + + .value { + font-size: 28px; + margin-right: 4px; + } + + .measurement { + font-size: 18px; + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card": HuiStatisticCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 8d7fe21412..b697e7c48f 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,4 +1,4 @@ -import { StatisticType } from "../../../data/recorder"; +import { Statistic, StatisticType } from "../../../data/recorder"; import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace"; import { FullCalendarView, TranslationDict } from "../../../types"; import { Condition } from "../common/validate-condition"; @@ -10,6 +10,7 @@ import { LovelaceRowConfig, } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { HaDurationData } from "../../../components/ha-duration-input"; export interface AlarmPanelCardConfig extends LovelaceCardConfig { entity: string; @@ -309,6 +310,18 @@ export interface StatisticsGraphCardConfig extends LovelaceCardConfig { chart_type?: "line" | "bar"; } +export interface StatisticCardConfig extends LovelaceCardConfig { + title?: string; + entities: Array; + period: { + fixed_period?: { start: string; end: string }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }; + stat_type: keyof Statistic; + theme?: string; +} + export interface PictureCardConfig extends LovelaceCardConfig { image?: string; tap_action?: ActionConfig; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 115de4157b..98390dc9bf 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -79,6 +79,7 @@ const LAZY_LOAD_TYPES = { "shopping-list": () => import("../cards/hui-shopping-list-card"), starting: () => import("../cards/hui-starting-card"), "statistics-graph": () => import("../cards/hui-statistics-graph-card"), + statistic: () => import("../cards/hui-statistic-card"), "vertical-stack": () => import("../cards/hui-vertical-stack-card"), }; diff --git a/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts new file mode 100644 index 0000000000..f37f1a2b17 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts @@ -0,0 +1,266 @@ +import type { HassEntity } from "home-assistant-js-websocket/dist/types"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { any, assert, assign, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { domainIcon } from "../../../../common/entity/domain_icon"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import { deepEqual } from "../../../../common/util/deep-equal"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import { + getStatisticMetadata, + StatisticsMetaData, + statisticsMetaHasType, + StatisticType, +} from "../../../../data/recorder"; +import type { HomeAssistant } from "../../../../types"; +import type { StatisticCardConfig } from "../../cards/types"; +import { headerFooterConfigStructs } from "../../header-footer/structs"; +import type { LovelaceCardEditor } from "../../types"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + entity: optional(string()), + name: optional(string()), + icon: optional(string()), + unit: optional(string()), + stat_type: optional(string()), + period: optional(any()), + theme: optional(string()), + footer: optional(headerFooterConfigStructs), + }) +); + +const stat_types = ["mean", "min", "max", "change"] as const; + +const statTypeMap: Record = { + mean: "mean", + min: "min", + max: "max", + change: "sum", +}; + +const periods = { + today: { calendar: { period: "day" } }, + yesterday: { calendar: { period: "day", offset: -1 } }, + this_week: { calendar: { period: "week" } }, + last_week: { calendar: { period: "week", offset: -1 } }, + this_month: { calendar: { period: "month" } }, + last_month: { calendar: { period: "month", offset: -1 } }, + this_year: { calendar: { period: "year" } }, + last_year: { calendar: { period: "year", offset: -1 } }, +} as const; + +@customElement("hui-statistic-card-editor") +export class HuiStatisticCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _metadata?: StatisticsMetaData; + + public setConfig(config: StatisticCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + this._fetchMetadata(); + } + + firstUpdated() { + this._fetchMetadata().then(() => { + if (!this._config?.stat_type && this._config?.entity) { + fireEvent(this, "config-changed", { + config: { + ...this._config, + stat_type: this._metadata?.has_sum ? "change" : "mean", + }, + }); + } + }); + } + + private _data = memoizeOne((config: StatisticCardConfig) => { + if (!config || !config.period) { + return config; + } + for (const period of Object.values(periods)) { + if (deepEqual(period, config.period)) { + return { ...config, period }; + } + } + return config; + }); + + private _schema = memoizeOne( + ( + entity: string, + icon: string, + periodVal: any, + entityState: HassEntity, + localize: LocalizeFunc, + metadata?: StatisticsMetaData + ) => + [ + { name: "entity", required: true, selector: { statistic: {} } }, + { + name: "stat_type", + required: true, + selector: { + select: { + multiple: false, + options: stat_types.map((stat_type) => ({ + value: stat_type, + label: localize( + `ui.panel.lovelace.editor.card.statistic.stat_type_labels.${stat_type}` + ), + disabled: + !metadata || + !statisticsMetaHasType(metadata, statTypeMap[stat_type]), + })), + }, + }, + }, + { + name: "period", + required: true, + selector: Object.values(periods).includes(periodVal) + ? { + select: { + multiple: false, + options: Object.entries(periods).map( + ([periodKey, period]) => ({ + value: period, + label: + localize( + `ui.panel.lovelace.editor.card.statistic.periods.${periodKey}` + ) || periodKey, + }) + ), + }, + } + : { object: {} }, + }, + { + type: "grid", + name: "", + schema: [ + { name: "name", selector: { text: {} } }, + { + name: "icon", + selector: { + icon: { + placeholder: icon || entityState?.attributes.icon, + fallbackPath: + !icon && !entityState?.attributes.icon && entityState + ? domainIcon(computeDomain(entity), entityState) + : undefined, + }, + }, + }, + { name: "unit", selector: { text: {} } }, + { name: "theme", selector: { theme: {} } }, + ], + }, + ] as const + ); + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const entityState = this.hass.states[this._config.entity]; + + const data = this._data(this._config); + + const schema = this._schema( + this._config.entity, + this._config.icon, + data.period, + entityState, + this.hass.localize, + this._metadata + ); + + return html` + + `; + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )[0]; + } + + private async _valueChanged(ev: CustomEvent) { + const config = ev.detail.value as StatisticCardConfig; + Object.keys(config).forEach((k) => config[k] === "" && delete config[k]); + if ( + config.stat_type && + config.entity && + config.entity !== this._metadata?.statistic_id + ) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + if (metadata && !metadata.has_sum && config.stat_type === "change") { + config.stat_type = "mean"; + } + if (metadata && !metadata.has_mean && config.stat_type !== "change") { + config.stat_type = "change"; + } + } + if (!config.stat_type && config.entity) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + config.stat_type = metadata?.has_sum ? "change" : "mean"; + } + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + if (schema.name === "period") { + return this.hass!.localize( + "ui.panel.lovelace.editor.card.statistic.period" + ); + } + + if (schema.name === "theme") { + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.theme" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + } + + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card-editor": HuiStatisticCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 3f3addb715..9dbefa4506 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -37,6 +37,10 @@ export const coreCards: Card[] = [ type: "statistics-graph", showElement: false, }, + { + type: "statistic", + showElement: true, + }, { type: "humidifier", showElement: true, diff --git a/src/translations/en.json b/src/translations/en.json index 5aa0c39ff4..5f85ffaa98 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4065,6 +4065,28 @@ "pick_statistic": "Add a statistic", "picked_statistic": "Statistic" }, + "statistic": { + "name": "Statistic", + "description": "The Statistic card allows you to display a statistical value of an entity of a certain period.", + "period": "Period", + "stat_types": "Show stat", + "stat_type_labels": { + "mean": "Mean", + "min": "Min", + "max": "Max", + "change": "Change" + }, + "periods": { + "today": "Today", + "yesterday": "Yesterday", + "this_week": "This week", + "last_week": "Last week", + "this_month": "This month", + "last_month": "Last month", + "this_year": "This year", + "last_year": "Last year" + } + }, "horizontal-stack": { "name": "Horizontal Stack", "description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column." From 1122698351c6dfa57e5e90b4bf59676a3a32a90f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Oct 2022 22:01:13 +0200 Subject: [PATCH 8/9] Bumped version to 20221027.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf64f96ba5..06f5f3e167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20221026.0" +version = "20221027.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" From 03d03f99038a673271124caacbcdd89c7464ba97 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Oct 2022 22:02:28 +0200 Subject: [PATCH 9/9] 5 minute more info stats (#14221) --- src/dialogs/more-info/ha-more-info-history.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 679d0914a1..0177ca9eb9 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -123,7 +123,7 @@ export class MoreInfoHistory extends LitElement { subHours(new Date(), 24), undefined, [this.entityId], - "hour" + "5minute" ); return; }