Skip to content

Commit 30eb50a

Browse files
MisiuMindFreeze
andauthored
Add color setting for calendar entities (#28882)
* Enhance calendar entity options with color support and update UI components for color selection * Add loading spinner to calendar components and improve event loading state management * simplify * Remove redundant color change check in HuiCalendarCard update logic * Add color validation utility and update calendar components to use it. color need to be hex strings * Adds logic to reset the _eventsLoaded state to false when either the card configuration or the entity registry changes, ensuring events are reloaded appropriately. * remove casting * Use SubscribeMixin for entity registry subscription --------- Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
1 parent 567e8c5 commit 30eb50a

7 files changed

Lines changed: 265 additions & 22 deletions

File tree

src/common/color/compute-color.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,34 @@ export function computeCssColor(color: string): string {
3838
}
3939
return color;
4040
}
41+
42+
/**
43+
* Validates if a string is a valid color.
44+
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
45+
*/
46+
export function isValidColorString(color: string | undefined): boolean {
47+
if (!color || typeof color !== "string") {
48+
return false;
49+
}
50+
51+
// Check if it's a theme color
52+
if (THEME_COLORS.has(color)) {
53+
return true;
54+
}
55+
56+
// Check if it's a hex color
57+
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color)) {
58+
return true;
59+
}
60+
61+
// Check if it's a valid CSS color name by trying to parse it
62+
// Use CSS.supports() for a more efficient test without DOM manipulation
63+
// This checks if the browser recognizes the color value
64+
try {
65+
const style = new Option().style;
66+
style.color = color;
67+
return style.color !== "";
68+
} catch {
69+
return false;
70+
}
71+
}

src/data/calendar.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import {
2+
computeCssColor,
3+
isValidColorString,
4+
} from "../common/color/compute-color";
15
import { getColorByIndex } from "../common/color/colors";
26
import { computeDomain } from "../common/entity/compute_domain";
37
import { computeStateName } from "../common/entity/compute_state_name";
48
import type { HomeAssistant } from "../types";
59
import { isUnavailableState } from "./entity/entity";
10+
import type { EntityRegistryEntry } from "./entity/entity_registry";
611

712
export interface Calendar {
813
entity_id: string;
@@ -139,9 +144,13 @@ const getCalendarDate = (dateObj: any): string | undefined => {
139144

140145
export const getCalendars = (
141146
hass: HomeAssistant,
142-
element: Element
147+
element: Element,
148+
entityRegistry?: EntityRegistryEntry[]
143149
): Calendar[] => {
144150
const computedStyles = getComputedStyle(element);
151+
const entityOptionsMap = new Map(
152+
entityRegistry?.map((entry) => [entry.entity_id, entry.options]) ?? []
153+
);
145154
return Object.keys(hass.states)
146155
.filter(
147156
(eid) =>
@@ -150,11 +159,23 @@ export const getCalendars = (
150159
hass.entities[eid]?.hidden !== true
151160
)
152161
.sort()
153-
.map((eid, idx) => ({
154-
...hass.states[eid],
155-
name: computeStateName(hass.states[eid]),
156-
backgroundColor: getColorByIndex(idx, computedStyles),
157-
}));
162+
.map((eid, idx) => {
163+
const stateObj = hass.states[eid];
164+
const entityColor = entityOptionsMap.get(eid)?.calendar?.color;
165+
let backgroundColor: string;
166+
// Validate and use the color from entity registry if valid
167+
if (entityColor && isValidColorString(entityColor)) {
168+
backgroundColor = computeCssColor(entityColor);
169+
} else {
170+
// Fall back to default color by index
171+
backgroundColor = getColorByIndex(idx, computedStyles);
172+
}
173+
return {
174+
...stateObj,
175+
name: computeStateName(stateObj),
176+
backgroundColor,
177+
};
178+
});
158179
};
159180

160181
export const createCalendarEvent = (

src/data/entity/entity_registry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export interface AlarmControlPanelEntityOptions {
103103
default_code?: string | null;
104104
}
105105

106+
export interface CalendarEntityOptions {
107+
color?: string | null;
108+
}
109+
106110
export interface WeatherEntityOptions {
107111
precipitation_unit?: string | null;
108112
pressure_unit?: string | null;
@@ -120,6 +124,7 @@ export interface EntityRegistryOptions {
120124
number?: NumberEntityOptions;
121125
sensor?: SensorEntityOptions;
122126
alarm_control_panel?: AlarmControlPanelEntityOptions;
127+
calendar?: CalendarEntityOptions;
123128
lock?: LockEntityOptions;
124129
weather?: WeatherEntityOptions;
125130
light?: LightEntityOptions;
@@ -143,6 +148,7 @@ export interface EntityRegistryEntryUpdateParams {
143148
| NumberEntityOptions
144149
| LockEntityOptions
145150
| AlarmControlPanelEntityOptions
151+
| CalendarEntityOptions
146152
| WeatherEntityOptions
147153
| LightEntityOptions;
148154
aliases?: string[];

src/panels/calendar/ha-panel-calendar.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import "@home-assistant/webawesome/dist/components/divider/divider";
22
import { ResizeController } from "@lit-labs/observers/resize-controller";
33
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
4-
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
4+
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
5+
import type { CSSResultGroup, TemplateResult } from "lit";
56
import { LitElement, css, html, nothing } from "lit";
67
import { customElement, property, state } from "lit/decorators";
78
import { storage } from "../../common/decorators/storage";
@@ -16,19 +17,23 @@ import "../../components/ha-icon-button";
1617
import "../../components/ha-list";
1718
import "../../components/ha-list-item";
1819
import "../../components/ha-menu-button";
20+
import "../../components/ha-spinner";
1921
import "../../components/ha-state-icon";
2022
import "../../components/ha-svg-icon";
2123
import "../../components/ha-two-pane-top-app-bar-fixed";
2224
import type { Calendar, CalendarEvent } from "../../data/calendar";
2325
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
26+
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
27+
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
2428
import { fetchIntegrationManifest } from "../../data/integration";
2529
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
30+
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
2631
import { haStyle } from "../../resources/styles";
2732
import type { CalendarViewChanged, HomeAssistant } from "../../types";
2833
import "./ha-full-calendar";
2934

3035
@customElement("ha-panel-calendar")
31-
class PanelCalendar extends LitElement {
36+
class PanelCalendar extends SubscribeMixin(LitElement) {
3237
@property({ attribute: false }) public hass!: HomeAssistant;
3338

3439
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -41,6 +46,8 @@ class PanelCalendar extends LitElement {
4146

4247
@state() private _error?: string = undefined;
4348

49+
@state() private _entityRegistry?: EntityRegistryEntry[];
50+
4451
@state()
4552
@storage({
4653
key: "deSelectedCalendars",
@@ -77,14 +84,46 @@ class PanelCalendar extends LitElement {
7784
this.mobile = ev.matches;
7885
};
7986

80-
public willUpdate(changedProps: PropertyValues): void {
81-
super.willUpdate(changedProps);
82-
if (!this.hasUpdated) {
83-
this._calendars = getCalendars(this.hass, this);
84-
}
87+
public hassSubscribe(): UnsubscribeFunc[] {
88+
return [
89+
subscribeEntityRegistry(this.hass.connection!, (entities) => {
90+
this._entityRegistry = entities;
91+
// Refresh calendars when entity registry updates (includes color changes)
92+
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
93+
// Refetch events if view dates are available (handles both initial load and color updates)
94+
if (this._start && this._end) {
95+
this._fetchEvents(
96+
this._start,
97+
this._end,
98+
this._selectedCalendars
99+
).then((result) => {
100+
this._events = result.events;
101+
this._handleErrors(result.errors);
102+
});
103+
}
104+
}),
105+
];
85106
}
86107

87108
protected render(): TemplateResult {
109+
if (!this._entityRegistry) {
110+
return html`
111+
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
112+
<ha-menu-button
113+
slot="navigationIcon"
114+
.hass=${this.hass}
115+
.narrow=${this.narrow}
116+
></ha-menu-button>
117+
<div slot="title">
118+
${this.hass.localize("ui.components.calendar.my_calendars")}
119+
</div>
120+
<div class="loading">
121+
<ha-spinner></ha-spinner>
122+
</div>
123+
</ha-two-pane-top-app-bar-fixed>
124+
`;
125+
}
126+
88127
const calendarItems = this._calendars.map(
89128
(selCal) => html`
90129
<ha-dropdown-item
@@ -220,7 +259,7 @@ class PanelCalendar extends LitElement {
220259
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
221260
dialogClosedCallback: ({ flowFinished }) => {
222261
if (flowFinished) {
223-
this._calendars = getCalendars(this.hass, this);
262+
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
224263
}
225264
},
226265
});
@@ -301,6 +340,13 @@ class PanelCalendar extends LitElement {
301340
:host([mobile]) {
302341
padding-left: unset;
303342
}
343+
.loading {
344+
display: flex;
345+
align-items: center;
346+
justify-content: center;
347+
padding: var(--ha-space-8);
348+
min-height: 400px;
349+
}
304350
`,
305351
];
306352
}

src/panels/config/entities/entity-registry-settings-editor.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
import { copyToClipboard } from "../../../common/util/copy-clipboard";
2222
import "../../../components/ha-alert";
2323
import "../../../components/ha-area-picker";
24+
import "../../../components/ha-color-picker";
2425
import "../../../components/ha-icon";
2526
import "../../../components/ha-icon-button-next";
2627
import "../../../components/ha-icon-picker";
@@ -53,6 +54,7 @@ import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
5354
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
5455
import type {
5556
AlarmControlPanelEntityOptions,
57+
CalendarEntityOptions,
5658
EntityRegistryEntry,
5759
EntityRegistryEntryUpdateParams,
5860
ExtEntityRegistryEntry,
@@ -195,6 +197,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
195197

196198
@state() private _defaultCode?: string | null;
197199

200+
@state() private _calendarColor?: string | null;
201+
198202
@state() private _noDeviceArea?: boolean;
199203

200204
private _origEntityId!: string;
@@ -253,6 +257,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
253257
this._defaultCode = this.entry.options?.alarm_control_panel?.default_code;
254258
}
255259

260+
if (domain === "calendar") {
261+
this._calendarColor = this.entry.options?.calendar?.color;
262+
}
263+
256264
if (domain === "weather") {
257265
const stateObj: HassEntity | undefined =
258266
this.hass.states[this.entry.entity_id];
@@ -596,6 +604,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
596604
></ha-textfield>
597605
`
598606
: ""}
607+
${domain === "calendar"
608+
? html`
609+
<ha-color-picker
610+
.hass=${this.hass}
611+
.value=${this._calendarColor ?? ""}
612+
.label=${this.hass.localize(
613+
"ui.dialogs.entity_registry.editor.calendar_color"
614+
)}
615+
.disabled=${this.disabled}
616+
@value-changed=${this._calendarColorChanged}
617+
></ha-color-picker>
618+
`
619+
: ""}
599620
${domain === "sensor" &&
600621
this._deviceClass &&
601622
stateObj?.attributes.unit_of_measurement &&
@@ -1097,6 +1118,15 @@ export class EntityRegistrySettingsEditor extends LitElement {
10971118
(params.options as AlarmControlPanelEntityOptions).default_code =
10981119
this._defaultCode;
10991120
}
1121+
if (domain === "calendar") {
1122+
const currentColor = this.entry.options?.calendar?.color ?? null;
1123+
const newColor = this._calendarColor ?? null;
1124+
if (currentColor !== newColor) {
1125+
params.options_domain = domain;
1126+
params.options = this.entry.options?.calendar || {};
1127+
(params.options as CalendarEntityOptions).color = this._calendarColor;
1128+
}
1129+
}
11001130
if (
11011131
domain === "weather" &&
11021132
(stateObj?.attributes?.precipitation_unit !== this._precipitation_unit ||
@@ -1328,6 +1358,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
13281358
this._defaultCode = ev.target.value === "" ? null : ev.target.value;
13291359
}
13301360

1361+
private _calendarColorChanged(ev: CustomEvent): void {
1362+
fireEvent(this, "change");
1363+
this._calendarColor = ev.detail.value || null;
1364+
}
1365+
13311366
private _precipitationUnitChanged(ev): void {
13321367
fireEvent(this, "change");
13331368
this._precipitation_unit = ev.target.value;

0 commit comments

Comments
 (0)