Skip to content
Merged
31 changes: 31 additions & 0 deletions src/common/color/compute-color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
33 changes: 27 additions & 6 deletions src/data/calendar.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) =>
Expand All @@ -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 = (
Expand Down
6 changes: 6 additions & 0 deletions src/data/entity/entity_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -120,6 +124,7 @@ export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
alarm_control_panel?: AlarmControlPanelEntityOptions;
calendar?: CalendarEntityOptions;
lock?: LockEntityOptions;
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
Expand All @@ -143,6 +148,7 @@ export interface EntityRegistryEntryUpdateParams {
| NumberEntityOptions
| LockEntityOptions
| AlarmControlPanelEntityOptions
| CalendarEntityOptions
| WeatherEntityOptions
| LightEntityOptions;
aliases?: string[];
Expand Down
62 changes: 54 additions & 8 deletions src/panels/calendar/ha-panel-calendar.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -41,6 +46,8 @@ class PanelCalendar extends LitElement {

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

@state() private _entityRegistry?: EntityRegistryEntry[];

@state()
@storage({
key: "deSelectedCalendars",
Expand Down Expand Up @@ -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`
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div slot="title">
${this.hass.localize("ui.components.calendar.my_calendars")}
</div>
<div class="loading">
<ha-spinner></ha-spinner>
</div>
</ha-two-pane-top-app-bar-fixed>
`;
}

const calendarItems = this._calendars.map(
(selCal) => html`
<ha-dropdown-item
Expand Down Expand Up @@ -220,7 +259,7 @@ class PanelCalendar extends LitElement {
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
dialogClosedCallback: ({ flowFinished }) => {
if (flowFinished) {
this._calendars = getCalendars(this.hass, this);
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
}
},
});
Expand Down Expand Up @@ -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;
}
`,
];
}
Expand Down
35 changes: 35 additions & 0 deletions src/panels/config/entities/entity-registry-settings-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -596,6 +604,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
></ha-textfield>
`
: ""}
${domain === "calendar"
? html`
<ha-color-picker
.hass=${this.hass}
.value=${this._calendarColor ?? ""}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.calendar_color"
)}
.disabled=${this.disabled}
@value-changed=${this._calendarColorChanged}
></ha-color-picker>
`
: ""}
${domain === "sensor" &&
this._deviceClass &&
stateObj?.attributes.unit_of_measurement &&
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading