diff --git a/src/common/color/compute-color.ts b/src/common/color/compute-color.ts index 175250cefb7e..139ea7d79e3f 100644 --- a/src/common/color/compute-color.ts +++ b/src/common/color/compute-color.ts @@ -38,3 +38,34 @@ export function computeCssColor(color: string): string { } return color; } + +/** + * Validates if a string is a valid color. + * Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names. + */ +export function isValidColorString(color: string | undefined): boolean { + if (!color || typeof color !== "string") { + return false; + } + + // Check if it's a theme color + if (THEME_COLORS.has(color)) { + return true; + } + + // Check if it's a hex color + if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color)) { + return true; + } + + // Check if it's a valid CSS color name by trying to parse it + // Use CSS.supports() for a more efficient test without DOM manipulation + // This checks if the browser recognizes the color value + try { + const style = new Option().style; + style.color = color; + return style.color !== ""; + } catch { + return false; + } +} diff --git a/src/data/calendar.ts b/src/data/calendar.ts index 3aa69f6250bf..6cbe60556455 100644 --- a/src/data/calendar.ts +++ b/src/data/calendar.ts @@ -1,8 +1,13 @@ +import { + computeCssColor, + isValidColorString, +} from "../common/color/compute-color"; import { getColorByIndex } from "../common/color/colors"; import { computeDomain } from "../common/entity/compute_domain"; import { computeStateName } from "../common/entity/compute_state_name"; import type { HomeAssistant } from "../types"; import { isUnavailableState } from "./entity/entity"; +import type { EntityRegistryEntry } from "./entity/entity_registry"; export interface Calendar { entity_id: string; @@ -139,9 +144,13 @@ const getCalendarDate = (dateObj: any): string | undefined => { export const getCalendars = ( hass: HomeAssistant, - element: Element + element: Element, + entityRegistry?: EntityRegistryEntry[] ): Calendar[] => { const computedStyles = getComputedStyle(element); + const entityOptionsMap = new Map( + entityRegistry?.map((entry) => [entry.entity_id, entry.options]) ?? [] + ); return Object.keys(hass.states) .filter( (eid) => @@ -150,11 +159,23 @@ export const getCalendars = ( hass.entities[eid]?.hidden !== true ) .sort() - .map((eid, idx) => ({ - ...hass.states[eid], - name: computeStateName(hass.states[eid]), - backgroundColor: getColorByIndex(idx, computedStyles), - })); + .map((eid, idx) => { + const stateObj = hass.states[eid]; + const entityColor = entityOptionsMap.get(eid)?.calendar?.color; + let backgroundColor: string; + // Validate and use the color from entity registry if valid + if (entityColor && isValidColorString(entityColor)) { + backgroundColor = computeCssColor(entityColor); + } else { + // Fall back to default color by index + backgroundColor = getColorByIndex(idx, computedStyles); + } + return { + ...stateObj, + name: computeStateName(stateObj), + backgroundColor, + }; + }); }; export const createCalendarEvent = ( diff --git a/src/data/entity/entity_registry.ts b/src/data/entity/entity_registry.ts index 4c9774e3aaff..16f1ce307faa 100644 --- a/src/data/entity/entity_registry.ts +++ b/src/data/entity/entity_registry.ts @@ -103,6 +103,10 @@ export interface AlarmControlPanelEntityOptions { default_code?: string | null; } +export interface CalendarEntityOptions { + color?: string | null; +} + export interface WeatherEntityOptions { precipitation_unit?: string | null; pressure_unit?: string | null; @@ -120,6 +124,7 @@ export interface EntityRegistryOptions { number?: NumberEntityOptions; sensor?: SensorEntityOptions; alarm_control_panel?: AlarmControlPanelEntityOptions; + calendar?: CalendarEntityOptions; lock?: LockEntityOptions; weather?: WeatherEntityOptions; light?: LightEntityOptions; @@ -143,6 +148,7 @@ export interface EntityRegistryEntryUpdateParams { | NumberEntityOptions | LockEntityOptions | AlarmControlPanelEntityOptions + | CalendarEntityOptions | WeatherEntityOptions | LightEntityOptions; aliases?: string[]; diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index c2ca2325ad33..8bf0dd52601c 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -1,7 +1,8 @@ import "@home-assistant/webawesome/dist/components/divider/divider"; import { ResizeController } from "@lit-labs/observers/resize-controller"; import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js"; -import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { CSSResultGroup, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { storage } from "../../common/decorators/storage"; @@ -16,19 +17,23 @@ import "../../components/ha-icon-button"; import "../../components/ha-list"; import "../../components/ha-list-item"; import "../../components/ha-menu-button"; +import "../../components/ha-spinner"; import "../../components/ha-state-icon"; import "../../components/ha-svg-icon"; import "../../components/ha-two-pane-top-app-bar-fixed"; import type { Calendar, CalendarEvent } from "../../data/calendar"; import { fetchCalendarEvents, getCalendars } from "../../data/calendar"; +import type { EntityRegistryEntry } from "../../data/entity/entity_registry"; +import { subscribeEntityRegistry } from "../../data/entity/entity_registry"; import { fetchIntegrationManifest } from "../../data/integration"; import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { haStyle } from "../../resources/styles"; import type { CalendarViewChanged, HomeAssistant } from "../../types"; import "./ha-full-calendar"; @customElement("ha-panel-calendar") -class PanelCalendar extends LitElement { +class PanelCalendar extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean, reflect: true }) public narrow = false; @@ -41,6 +46,8 @@ class PanelCalendar extends LitElement { @state() private _error?: string = undefined; + @state() private _entityRegistry?: EntityRegistryEntry[]; + @state() @storage({ key: "deSelectedCalendars", @@ -77,14 +84,46 @@ class PanelCalendar extends LitElement { this.mobile = ev.matches; }; - public willUpdate(changedProps: PropertyValues): void { - super.willUpdate(changedProps); - if (!this.hasUpdated) { - this._calendars = getCalendars(this.hass, this); - } + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entityRegistry = entities; + // Refresh calendars when entity registry updates (includes color changes) + this._calendars = getCalendars(this.hass, this, this._entityRegistry); + // Refetch events if view dates are available (handles both initial load and color updates) + if (this._start && this._end) { + this._fetchEvents( + this._start, + this._end, + this._selectedCalendars + ).then((result) => { + this._events = result.events; + this._handleErrors(result.errors); + }); + } + }), + ]; } protected render(): TemplateResult { + if (!this._entityRegistry) { + return html` + + + + ${this.hass.localize("ui.components.calendar.my_calendars")} + + + + + + `; + } + const calendarItems = this._calendars.map( (selCal) => html` { if (flowFinished) { - this._calendars = getCalendars(this.hass, this); + this._calendars = getCalendars(this.hass, this, this._entityRegistry); } }, }); @@ -301,6 +340,13 @@ class PanelCalendar extends LitElement { :host([mobile]) { padding-left: unset; } + .loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--ha-space-8); + min-height: 400px; + } `, ]; } diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index 30fe6e87adfa..18c9f179c16c 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -21,6 +21,7 @@ import type { import { copyToClipboard } from "../../../common/util/copy-clipboard"; import "../../../components/ha-alert"; import "../../../components/ha-area-picker"; +import "../../../components/ha-color-picker"; import "../../../components/ha-icon"; import "../../../components/ha-icon-button-next"; import "../../../components/ha-icon-picker"; @@ -53,6 +54,7 @@ import type { DeviceRegistryEntry } from "../../../data/device/device_registry"; import { updateDeviceRegistryEntry } from "../../../data/device/device_registry"; import type { AlarmControlPanelEntityOptions, + CalendarEntityOptions, EntityRegistryEntry, EntityRegistryEntryUpdateParams, ExtEntityRegistryEntry, @@ -195,6 +197,8 @@ export class EntityRegistrySettingsEditor extends LitElement { @state() private _defaultCode?: string | null; + @state() private _calendarColor?: string | null; + @state() private _noDeviceArea?: boolean; private _origEntityId!: string; @@ -253,6 +257,10 @@ export class EntityRegistrySettingsEditor extends LitElement { this._defaultCode = this.entry.options?.alarm_control_panel?.default_code; } + if (domain === "calendar") { + this._calendarColor = this.entry.options?.calendar?.color; + } + if (domain === "weather") { const stateObj: HassEntity | undefined = this.hass.states[this.entry.entity_id]; @@ -596,6 +604,19 @@ export class EntityRegistrySettingsEditor extends LitElement { > ` : ""} + ${domain === "calendar" + ? html` + + ` + : ""} ${domain === "sensor" && this._deviceClass && stateObj?.attributes.unit_of_measurement && @@ -1097,6 +1118,15 @@ export class EntityRegistrySettingsEditor extends LitElement { (params.options as AlarmControlPanelEntityOptions).default_code = this._defaultCode; } + if (domain === "calendar") { + const currentColor = this.entry.options?.calendar?.color ?? null; + const newColor = this._calendarColor ?? null; + if (currentColor !== newColor) { + params.options_domain = domain; + params.options = this.entry.options?.calendar || {}; + (params.options as CalendarEntityOptions).color = this._calendarColor; + } + } if ( domain === "weather" && (stateObj?.attributes?.precipitation_unit !== this._precipitation_unit || @@ -1328,6 +1358,11 @@ export class EntityRegistrySettingsEditor extends LitElement { this._defaultCode = ev.target.value === "" ? null : ev.target.value; } + private _calendarColorChanged(ev: CustomEvent): void { + fireEvent(this, "change"); + this._calendarColor = ev.detail.value || null; + } + private _precipitationUnitChanged(ev): void { fireEvent(this, "change"); this._precipitation_unit = ev.target.value; diff --git a/src/panels/lovelace/cards/hui-calendar-card.ts b/src/panels/lovelace/cards/hui-calendar-card.ts index e6ad8abdf1e8..45d1d3926cfd 100644 --- a/src/panels/lovelace/cards/hui-calendar-card.ts +++ b/src/panels/lovelace/cards/hui-calendar-card.ts @@ -1,14 +1,23 @@ +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { classMap } from "lit/directives/class-map"; import { customElement, property, state } from "lit/decorators"; +import { + computeCssColor, + isValidColorString, +} from "../../../common/color/compute-color"; import { getColorByIndex } from "../../../common/color/colors"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import type { HASSDomEvent } from "../../../common/dom/fire_event"; import { debounce } from "../../../common/util/debounce"; import "../../../components/ha-card"; +import "../../../components/ha-spinner"; import type { Calendar, CalendarEvent } from "../../../data/calendar"; import { fetchCalendarEvents } from "../../../data/calendar"; +import type { EntityRegistryEntry } from "../../../data/entity/entity_registry"; +import { subscribeEntityRegistry } from "../../../data/entity/entity_registry"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { CalendarViewChanged, FullCalendarView, @@ -25,7 +34,10 @@ import type { import type { CalendarCardConfig } from "./types"; @customElement("hui-calendar-card") -export class HuiCalendarCard extends LitElement implements LovelaceCard { +export class HuiCalendarCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ public static async getConfigElement(): Promise { await import("../editor/config-elements/hui-calendar-card-editor"); return document.createElement("hui-calendar-card-editor"); @@ -65,6 +77,10 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { @state() private _error?: string = undefined; + @state() private _entityRegistry?: EntityRegistryEntry[]; + + @state() private _eventsLoaded = false; + private _startDate?: Date; private _endDate?: Date; @@ -89,16 +105,45 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { public willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); + + // Don't build calendars until entity registry is loaded + if (!this._entityRegistry) { + return; + } + + // Reset loading state when config changes or entity registry updates + if (changedProps.has("_config") || changedProps.has("_entityRegistry")) { + this._eventsLoaded = false; + } + if ( !this.hasUpdated || - (changedProps.has("_config") && this._config?.entities) + (changedProps.has("_config") && this._config?.entities) || + changedProps.has("_entityRegistry") ) { const computedStyles = getComputedStyle(this); + const entityOptionsMap = new Map( + this._entityRegistry?.map((entry) => [ + entry.entity_id, + entry.options, + ]) ?? [] + ); if (this._config?.entities) { - this._calendars = this._config.entities.map((entity, idx) => ({ - entity_id: entity, - backgroundColor: getColorByIndex(idx, computedStyles), - })); + this._calendars = this._config.entities.map((entity, idx) => { + const entityColor = entityOptionsMap.get(entity)?.calendar?.color; + let backgroundColor: string; + // Validate and use the color from entity registry if valid + if (entityColor && isValidColorString(entityColor)) { + backgroundColor = computeCssColor(entityColor); + } else { + // Fall back to default color by index + backgroundColor = getColorByIndex(idx, computedStyles); + } + return { + entity_id: entity, + backgroundColor, + }; + }); } } } @@ -116,6 +161,14 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { }; } + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass!.connection!, (entities) => { + this._entityRegistry = entities; + }), + ]; + } + public connectedCallback(): void { super.connectedCallback(); this.updateComplete.then(() => this._attachObserver()); @@ -129,10 +182,12 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { } protected render() { - if (!this._config || !this.hass || !this._calendars.length) { + if (!this._config || !this.hass) { return nothing; } + const loading = !this._entityRegistry || !this._eventsLoaded; + const views: FullCalendarView[] = [ "dayGridMonth", "dayGridDay", @@ -141,31 +196,55 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { return html` - ${this._config.title} + ${this._config.title + ? html`${this._config.title}` + : nothing} + ${loading + ? html` + + ` + : nothing} `; } protected updated(changedProps: PropertyValues) { super.updated(changedProps); + if (!this._config || !this.hass) { return; } + // Refetch events when entity registry changes (to update colors) + if (changedProps.has("_entityRegistry") && this._entityRegistry) { + this._fetchCalendarEvents(); + } + + // If no calendars configured, mark events as loaded to hide spinner + if ( + this._entityRegistry && + !this._calendars.length && + !this._eventsLoaded + ) { + this._eventsLoaded = true; + } + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldConfig = changedProps.get("_config") as | CalendarCardConfig @@ -184,6 +263,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { private _handleViewChanged(ev: HASSDomEvent): void { this._startDate = ev.detail.start; this._endDate = ev.detail.end; + this._eventsLoaded = false; this._fetchCalendarEvents(); } @@ -200,6 +280,12 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { this._calendars ); this._events = result.events; + // Wait for component update and one animation frame for FullCalendar to render + this.updateComplete.then(() => { + requestAnimationFrame(() => { + this._eventsLoaded = true; + }); + }); if (result.errors.length > 0) { this._error = `${this.hass!.localize( @@ -253,7 +339,14 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { ha-full-calendar { --calendar-height: 400px; + display: block; + width: 100%; height: var(--calendar-height); + min-height: var(--calendar-height); + } + + ha-full-calendar.loading { + visibility: hidden; } ha-full-calendar.is-grid, @@ -267,6 +360,16 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { 100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px ); } + + .loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--card-background-color, var(--ha-card-background)); + z-index: 1; + } `; } diff --git a/src/translations/en.json b/src/translations/en.json index d69fdd2d3cfd..30b6fa927815 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1626,6 +1626,7 @@ "icon_error": "Icons should be in the format 'prefix:iconname', e.g. 'mdi:home'", "default_code": "Default code", "default_code_error": "Code does not match code format", + "calendar_color": "Calendar color", "entity_id": "Entity ID", "unit_of_measurement": "Unit of measurement", "precipitation_unit": "Precipitation unit",