From 92980dfddf944fceb8699caf972aa4494c70b63a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 17 Dec 2025 10:45:27 +0100 Subject: [PATCH] Show summaries at top on mobile, sidebar on desktop (#28573) * Use weather tile card and energy summary in home dashboard * Only use sidebar on desktop * Hide sidebar on mobile * Rename widget to summaries * Improve commonly used * Feedbacks * Use key instead of section --- src/data/lovelace/config/view.ts | 2 + src/panels/energy/ha-panel-energy.ts | 2 +- .../lovelace/cards/hui-home-summary-card.ts | 50 ++++- .../strategies/home/helpers/home-summaries.ts | 3 + .../home/home-overview-view-strategy.ts | 187 ++++++++++-------- .../lovelace/views/hui-sections-view.ts | 56 ++++-- src/panels/lovelace/views/hui-view-sidebar.ts | 47 ++++- src/translations/en.json | 7 +- 8 files changed, 247 insertions(+), 107 deletions(-) diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index 28e6b17e79..f2cb59c489 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -1,3 +1,4 @@ +import type { Condition } from "../../../panels/lovelace/common/validate-condition"; import type { MediaSelectorValue } from "../../selector"; import type { LovelaceBadgeConfig } from "./badge"; import type { LovelaceCardConfig } from "./card"; @@ -40,6 +41,7 @@ export interface LovelaceViewSidebarConfig { sections?: LovelaceSectionConfig[]; content_label?: string; sidebar_label?: string; + visibility?: Condition[]; } export interface LovelaceBaseViewConfig { diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index 0f25c9792a..c2be657714 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -182,7 +182,7 @@ class PanelEnergy extends LitElement { const validPaths = views.map((view) => view.path); const viewPath: string | undefined = this.route!.path.split("/")[1]; if (!viewPath || !validPaths.includes(viewPath)) { - navigate(`${this.route!.prefix}/${validPaths[0]}`); + navigate(`${this.route!.prefix}/${validPaths[0]}`, { replace: true }); } else { // Force hui-root to re-process the route by creating a new route object this.route = { ...this.route! }; diff --git a/src/panels/lovelace/cards/hui-home-summary-card.ts b/src/panels/lovelace/cards/hui-home-summary-card.ts index e7981dccbb..ac07b42129 100644 --- a/src/panels/lovelace/cards/hui-home-summary-card.ts +++ b/src/panels/lovelace/cards/hui-home-summary-card.ts @@ -1,9 +1,12 @@ +import { endOfDay, startOfDay } from "date-fns"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { computeCssColor } from "../../../common/color/compute-color"; +import { calcDate } from "../../../common/datetime/calc_date"; import { computeDomain } from "../../../common/entity/compute_domain"; import { findEntities, @@ -15,8 +18,15 @@ import "../../../components/ha-icon"; import "../../../components/ha-ripple"; import "../../../components/tile/ha-tile-icon"; import "../../../components/tile/ha-tile-info"; +import type { EnergyData } from "../../../data/energy"; +import { + computeConsumptionData, + formatConsumptionShort, + getEnergyDataCollection, + getSummedData, +} from "../../../data/energy"; import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; -import "../../../state-display/state-display"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../types"; import { actionHandler } from "../common/directives/action-handler-directive"; import { handleAction } from "../common/handle-action"; @@ -35,14 +45,41 @@ const COLORS: Record = { climate: "deep-orange", security: "blue-grey", media_players: "blue", + energy: "amber", }; @customElement("hui-home-summary-card") -export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { +export class HuiHomeSummaryCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: HomeSummaryCard; + @state() private _energyData?: EnergyData; + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + if (this._config?.summary !== "energy") { + return []; + } + const collection = getEnergyDataCollection(this.hass!, { + key: "energy_home_dashboard", + }); + // Ensure we always show today's energy data + collection.setPeriod( + calcDate(new Date(), startOfDay, this.hass!.locale, this.hass!.config), + calcDate(new Date(), endOfDay, this.hass!.locale, this.hass!.config) + ); + return [ + collection.subscribe((data) => { + this._energyData = data; + }), + ]; + } + public setConfig(config: HomeSummaryCard): void { this._config = config; } @@ -214,6 +251,15 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { }) : this.hass.localize("ui.card.home-summary.no_media_playing"); } + case "energy": { + if (!this._energyData) { + return ""; + } + const { summedData } = getSummedData(this._energyData); + const { consumption } = computeConsumptionData(summedData, undefined); + const totalConsumption = consumption.total.used_total; + return formatConsumptionShort(this.hass, totalConsumption, "kWh"); + } } return ""; } diff --git a/src/panels/lovelace/strategies/home/helpers/home-summaries.ts b/src/panels/lovelace/strategies/home/helpers/home-summaries.ts index 5f3c9557a1..76e08e5727 100644 --- a/src/panels/lovelace/strategies/home/helpers/home-summaries.ts +++ b/src/panels/lovelace/strategies/home/helpers/home-summaries.ts @@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [ "climate", "security", "media_players", + "energy", ] as const; export type HomeSummary = (typeof HOME_SUMMARIES)[number]; @@ -18,6 +19,7 @@ export const HOME_SUMMARIES_ICONS: Record = { climate: "mdi:home-thermometer", security: "mdi:security", media_players: "mdi:multimedia", + energy: "mdi:lightning-bolt", }; export const HOME_SUMMARIES_FILTERS: Record = { @@ -25,6 +27,7 @@ export const HOME_SUMMARIES_FILTERS: Record = { climate: climateEntityFilters, security: securityEntityFilters, media_players: [{ domain: "media_player", entity_category: "none" }], + energy: [], // Uses energy collection data }; export const getSummaryLabel = ( diff --git a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts index 2d69f42957..2c76214f18 100644 --- a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts @@ -21,7 +21,7 @@ import type { AreaCardConfig, HomeSummaryCard, MarkdownCardConfig, - WeatherForecastCardConfig, + TileCardConfig, } from "../../cards/types"; import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy"; import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries"; @@ -78,6 +78,11 @@ export class HomeOverviewViewStrategy extends ReactiveElement { media_query: "(min-width: 871px)", }; + const smallScreenCondition: Condition = { + condition: "screen", + media_query: "(max-width: 870px)", + }; + const floorsSections: LovelaceSectionConfig[] = []; for (const floorStructure of home.floors) { const floorId = floorStructure.id; @@ -136,7 +141,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement { ); const maxCommonControls = Math.max(8, favoriteEntities.length); - const commonControlsSection = { + const commonControlsSectionBase = { strategy: { type: "common-controls", limit: maxCommonControls, @@ -146,6 +151,20 @@ export class HomeOverviewViewStrategy extends ReactiveElement { column_span: maxColumns, } as LovelaceStrategySectionConfig; + const commonControlsSectionMobile = { + ...commonControlsSectionBase, + strategy: { + ...commonControlsSectionBase.strategy, + title: hass.localize("ui.panel.lovelace.strategy.home.commonly_used"), + }, + visibility: [smallScreenCondition], + } as LovelaceStrategySectionConfig; + + const commonControlsSectionDesktop = { + ...commonControlsSectionBase, + visibility: [largeScreenCondition], + } as LovelaceStrategySectionConfig; + const allEntities = Object.keys(hass.states); const mediaPlayerFilter = HOME_SUMMARIES_FILTERS.media_players.map( @@ -170,6 +189,26 @@ export class HomeOverviewViewStrategy extends ReactiveElement { const hasClimate = findEntities(allEntities, climateFilters).length > 0; const hasSecurity = findEntities(allEntities, securityFilters).length > 0; + const weatherFilter = generateEntityFilter(hass, { + domain: "weather", + entity_category: "none", + }); + + const weatherEntity = Object.keys(hass.states) + .filter(weatherFilter) + .sort()[0]; + + const energyPrefs = isComponentLoaded(hass, "energy") + ? // It raises if not configured, just swallow that. + await getEnergyPreferences(hass).catch(() => undefined) + : undefined; + + const hasEnergy = + energyPrefs?.energy_sources.some( + (source) => source.type === "grid" && source.flow_from.length > 0 + ) ?? false; + + // Build summary cards (used in both mobile section and sidebar) const summaryCards: LovelaceCardConfig[] = [ hasLights && ({ @@ -179,9 +218,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement { action: "navigate", navigation_path: "/light?historyBack=1", }, - grid_options: { - columns: 12, - }, } satisfies HomeSummaryCard), hasClimate && ({ @@ -191,9 +227,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement { action: "navigate", navigation_path: "/climate?historyBack=1", }, - grid_options: { - columns: 12, - }, } satisfies HomeSummaryCard), hasSecurity && ({ @@ -203,9 +236,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement { action: "navigate", navigation_path: "/security?historyBack=1", }, - grid_options: { - columns: 12, - }, } satisfies HomeSummaryCard), hasMediaPlayers && ({ @@ -215,75 +245,67 @@ export class HomeOverviewViewStrategy extends ReactiveElement { action: "navigate", navigation_path: "media-players", }, - grid_options: { - columns: 12, + } satisfies HomeSummaryCard), + weatherEntity && + ({ + type: "tile", + entity: weatherEntity, + name: hass.localize( + "ui.panel.lovelace.strategy.home.summary_list.weather" + ), + state_content: ["temperature", "state"], + } satisfies TileCardConfig), + hasEnergy && + ({ + type: "home-summary", + summary: "energy", + tap_action: { + action: "navigate", + navigation_path: "/energy?historyBack=1", }, } satisfies HomeSummaryCard), ].filter(Boolean) as LovelaceCardConfig[]; - const forYouSection: LovelaceSectionConfig = { - type: "grid", - cards: [ - { - type: "heading", - heading: hass.localize("ui.panel.lovelace.strategy.home.for_you"), - heading_style: "title", - visibility: [largeScreenCondition], - }, - ], - }; + // Build summary cards for sidebar (full width: columns 12) + const sidebarSummaryCards = summaryCards.map((card) => ({ + ...card, + grid_options: { columns: 12 }, + })); - const widgetSection: LovelaceSectionConfig = { - cards: [], - }; + // Build summary cards for mobile section (half width: columns 6) + const mobileSummaryCards = summaryCards.map((card) => ({ + ...card, + grid_options: { columns: 6 }, + })); - if (summaryCards.length) { - widgetSection.cards!.push(...summaryCards); - } + // Mobile summary section (visible on small screens only) + const mobileSummarySection: LovelaceSectionConfig | undefined = + mobileSummaryCards.length > 0 + ? { + type: "grid", + column_span: maxColumns, + visibility: [smallScreenCondition], + cards: mobileSummaryCards, + } + : undefined; - const weatherFilter = generateEntityFilter(hass, { - domain: "weather", - entity_category: "none", - }); - - const weatherEntity = Object.keys(hass.states) - .filter(weatherFilter) - .sort()[0]; - - if (weatherEntity) { - widgetSection.cards!.push({ - type: "weather-forecast", - entity: weatherEntity, - show_forecast: false, - show_current: true, - grid_options: { - columns: 12, - rows: "auto", - }, - } as WeatherForecastCardConfig); - } - - const energyPrefs = isComponentLoaded(hass, "energy") - ? // It raises if not configured, just swallow that. - await getEnergyPreferences(hass).catch(() => undefined) - : undefined; - - if (energyPrefs) { - const grid = energyPrefs.energy_sources.find( - (source) => source.type === "grid" - ); - - if (grid && grid.flow_from.length > 0) { - widgetSection.cards!.push({ - title: hass.localize( - "ui.panel.lovelace.cards.energy.energy_distribution.title_today" - ), - type: "energy-distribution", - collection_key: "energy_home_dashboard", - link_dashboard: true, - }); - } - } + // Sidebar section + const sidebarSection: LovelaceSectionConfig | undefined = + sidebarSummaryCards.length > 0 + ? { + type: "grid", + cards: [ + { + type: "heading", + heading: hass.localize( + "ui.panel.lovelace.strategy.home.for_you" + ), + heading_style: "title", + }, + ...sidebarSummaryCards, + ], + } + : undefined; const sections = ( [ @@ -298,7 +320,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement { }, ], }, - commonControlsSection, + mobileSummarySection, + commonControlsSectionMobile, + commonControlsSectionDesktop, ...floorsSections, ] satisfies (LovelaceSectionRawConfig | undefined)[] ).filter(Boolean) as LovelaceSectionRawConfig[]; @@ -315,11 +339,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement { content: `## ${hass.localize("ui.panel.lovelace.strategy.home.welcome_user", { user: "{{ user }}" })}`, } satisfies MarkdownCardConfig, }, - sidebar: { - sections: [forYouSection, widgetSection], - content_label: hass.localize("ui.panel.lovelace.strategy.home.home"), - sidebar_label: hass.localize("ui.panel.lovelace.strategy.home.for_you"), - }, + ...(sidebarSection && { + sidebar: { + sections: [sidebarSection], + content_label: hass.localize("ui.panel.lovelace.strategy.home.home"), + sidebar_label: hass.localize( + "ui.panel.lovelace.strategy.home.for_you" + ), + visibility: [largeScreenCondition], + }, + }), }; } } diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index d9c6783561..2feb25992a 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -61,7 +61,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement { @state() _dragging = false; - @state() private _showSidebar = false; + @state() private _sidebarTabActive = false; + + @state() private _sidebarVisible = true; private _contentScrollTop = 0; @@ -123,7 +125,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { "section-visibility-changed", this._sectionVisibilityChanged ); - this._showSidebar = Boolean(window.history.state?.sidebar); + this._sidebarTabActive = Boolean(window.history.state?.sidebar); } disconnectedCallback(): void { @@ -144,26 +146,25 @@ export class SectionsView extends LitElement implements LovelaceViewElement { if (!this.lovelace) return nothing; const sections = this.sections; - const totalSectionCount = - this._sectionColumnCount + - (this.lovelace?.editMode ? 1 : 0) + - (this._config?.sidebar ? 1 : 0); const editMode = this.lovelace.editMode; + const hasSidebar = + this._config?.sidebar && (this._sidebarVisible || editMode); + + const totalSectionCount = + this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0); const maxColumnCount = this._columnsController.value ?? 1; const columnCount = Math.min(maxColumnCount, totalSectionCount); // On mobile with sidebar, use full width for whichever view is active const contentColumnCount = - this._config?.sidebar && !this.narrow - ? Math.max(1, columnCount - 1) - : columnCount; + hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount; return html`
- ${this.narrow && this._config?.sidebar + ${this.narrow && hasSidebar ? html`
@@ -211,7 +212,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
${repeat( @@ -290,13 +291,16 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ? html` ` : nothing} @@ -414,36 +418,46 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const newValue = ev.detail.value; const shouldShowSidebar = newValue === "sidebar"; - if (shouldShowSidebar !== this._showSidebar) { + if (shouldShowSidebar !== this._sidebarTabActive) { this._toggleView(); } } private _toggleView() { // Save current scroll position - if (this._showSidebar) { + if (this._sidebarTabActive) { this._sidebarScrollTop = window.scrollY; } else { this._contentScrollTop = window.scrollY; } - this._showSidebar = !this._showSidebar; + this._sidebarTabActive = !this._sidebarTabActive; // Add sidebar state to history window.history.replaceState( - { ...window.history.state, sidebar: this._showSidebar }, + { ...window.history.state, sidebar: this._sidebarTabActive }, "" ); // Restore scroll position after view updates this.updateComplete.then(() => { - const scrollY = this._showSidebar + const scrollY = this._sidebarTabActive ? this._sidebarScrollTop : this._contentScrollTop; window.scrollTo(0, scrollY); }); } + private _handleSidebarVisibilityChanged = ( + e: CustomEvent<{ visible: boolean }> + ) => { + this._sidebarVisible = e.detail.visible; + // Reset sidebar tab when sidebar becomes hidden + if (!e.detail.visible) { + this._sidebarTabActive = false; + } + }; + static styles = css` :host { --row-height: var(--ha-view-sections-row-height, 56px); diff --git a/src/panels/lovelace/views/hui-view-sidebar.ts b/src/panels/lovelace/views/hui-view-sidebar.ts index 2bcbd6bf9f..f40084df4a 100644 --- a/src/panels/lovelace/views/hui-view-sidebar.ts +++ b/src/panels/lovelace/views/hui-view-sidebar.ts @@ -1,15 +1,22 @@ +import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; +import { fireEvent } from "../../../common/dom/fire_event"; import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view"; +import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin"; import type { HomeAssistant } from "../../../types"; +import { checkConditionsMet } from "../common/validate-condition"; import "../sections/hui-section"; import type { Lovelace } from "../types"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; export const DEFAULT_VIEW_SIDEBAR_LAYOUT = "start"; @customElement("hui-view-sidebar") -export class HuiViewSidebar extends LitElement { +export class HuiViewSidebar extends ConditionalListenerMixin( + LitElement +) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public lovelace!: Lovelace; @@ -18,6 +25,38 @@ export class HuiViewSidebar extends LitElement { @property({ attribute: false }) public viewIndex!: number; + private _visible = true; + + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (changedProperties.has("hass") || changedProperties.has("config")) { + this._updateVisibility(); + } + } + + protected _updateVisibility(conditionsMet?: boolean) { + if (!this.hass || !this.config) return; + + const visible = + conditionsMet ?? + (!this.config.visibility || + checkConditionsMet(this.config.visibility, this.hass)); + + if (visible !== this._visible) { + this._visible = visible; + fireEvent(this, "sidebar-visibility-changed", { visible }); + } + } + + private _sectionConfigKeys = new WeakMap(); + + private _getSectionKey(section: LovelaceSectionConfig) { + if (!this._sectionConfigKeys.has(section)) { + this._sectionConfigKeys.set(section, Math.random().toString()); + } + return this._sectionConfigKeys.get(section)!; + } + render() { if (!this.lovelace) return nothing; @@ -26,7 +65,8 @@ export class HuiViewSidebar extends LitElement { return html`
${repeat( - this.config?.sections || [], + this.config?.sections ?? [], + (section) => this._getSectionKey(section), (section) => html`