1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

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
This commit is contained in:
Paul Bottein
2025-12-17 10:45:27 +01:00
committed by GitHub
parent 9f3d6e1fea
commit 92980dfddf
8 changed files with 247 additions and 107 deletions

View File

@@ -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 {

View File

@@ -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! };

View File

@@ -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<HomeSummary, string> = {
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 "";
}

View File

@@ -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<HomeSummary, string> = {
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
energy: "mdi:lightning-bolt",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
@@ -25,6 +27,7 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
climate: climateEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
energy: [], // Uses energy collection data
};
export const getSummaryLabel = (

View File

@@ -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 = {
// Build summary cards for sidebar (full width: columns 12)
const sidebarSummaryCards = summaryCards.map((card) => ({
...card,
grid_options: { columns: 12 },
}));
// Build summary cards for mobile section (half width: columns 6)
const mobileSummaryCards = summaryCards.map((card) => ({
...card,
grid_options: { columns: 6 },
}));
// 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;
// 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",
visibility: [largeScreenCondition],
},
],
};
const widgetSection: LovelaceSectionConfig = {
cards: [],
};
if (summaryCards.length) {
widgetSection.cards!.push(...summaryCards);
}
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"
heading: hass.localize(
"ui.panel.lovelace.strategy.home.for_you"
),
type: "energy-distribution",
collection_key: "energy_home_dashboard",
link_dashboard: true,
});
}
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,
},
...(sidebarSection && {
sidebar: {
sections: [forYouSection, widgetSection],
sections: [sidebarSection],
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
sidebar_label: hass.localize("ui.panel.lovelace.strategy.home.for_you"),
sidebar_label: hass.localize(
"ui.panel.lovelace.strategy.home.for_you"
),
visibility: [largeScreenCondition],
},
}),
};
}
}

View File

@@ -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`
<div
class="wrapper ${classMap({
"top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(this._config?.sidebar),
"has-sidebar": Boolean(hasSidebar),
narrow: this.narrow,
})}"
style=${styleMap({
@@ -178,20 +179,20 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.viewIndex=${this.index}
.config=${this._config?.header}
></hui-view-header>
${this.narrow && this._config?.sidebar
${this.narrow && hasSidebar
? html`
<div class="mobile-tabs">
<ha-control-select
.value=${this._showSidebar ? "sidebar" : "content"}
.value=${this._sidebarTabActive ? "sidebar" : "content"}
@value-changed=${this._viewChanged}
.options=${[
{
value: "content",
label: this._config.sidebar.content_label,
label: this._config!.sidebar!.content_label,
},
{
value: "sidebar",
label: this._config.sidebar.sidebar_label,
label: this._config!.sidebar!.sidebar_label,
},
]}
>
@@ -211,7 +212,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement),
"mobile-hidden": this.narrow && this._showSidebar,
"mobile-hidden": this.narrow && this._sidebarTabActive,
})}"
>
${repeat(
@@ -290,13 +291,16 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
? html`
<hui-view-sidebar
class=${classMap({
"mobile-hidden": this.narrow && !this._showSidebar,
"mobile-hidden":
!hasSidebar || (this.narrow && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.config=${this._config.sidebar}
@sidebar-visibility-changed=${this
._handleSidebarVisibilityChanged}
></hui-view-sidebar>
`
: 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);

View File

@@ -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<LovelaceViewSidebarConfig>(
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<LovelaceSectionConfig, string>();
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`
<div class="container">
${repeat(
this.config?.sections || [],
this.config?.sections ?? [],
(section) => this._getSectionKey(section),
(section) => html`
<hui-section
.config=${section}
@@ -54,4 +94,7 @@ declare global {
interface HTMLElementTagNameMap {
"hui-view-sidebar": HuiViewSidebar;
}
interface HASSDomEvents {
"sidebar-visibility-changed": { visible: boolean };
}
}

View File

@@ -7163,7 +7163,9 @@
},
"home": {
"summary_list": {
"media_players": "Media players"
"media_players": "Media players",
"weather": "Weather",
"energy": "Today's energy"
},
"welcome_user": "Welcome {user}",
"summaries": "Summaries",
@@ -7174,7 +7176,8 @@
"scenes": "Scenes",
"automations": "Automations",
"for_you": "For you",
"home": "Home"
"home": "Home",
"commonly_used": "Commonly used"
},
"common_controls": {
"not_loaded": "Usage Prediction integration is not loaded.",