Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 95 additions & 43 deletions src/data/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export const enum CalendarEntityFeature {
UPDATE_EVENT = 4,
}

/** Type for date values that can come from REST API or subscription */
type CalendarDateValue = string | { dateTime: string } | { date: string };

export const fetchCalendarEvents = async (
hass: HomeAssistant,
start: Date,
Expand All @@ -65,19 +68,19 @@ export const fetchCalendarEvents = async (

const calEvents: CalendarEvent[] = [];
const errors: string[] = [];
const promises: Promise<CalendarEvent[]>[] = [];
const promises: Promise<CalendarEventApiData[]>[] = [];

calendars.forEach((cal) => {
promises.push(
hass.callApi<CalendarEvent[]>(
hass.callApi<CalendarEventApiData[]>(
"GET",
`calendars/${cal.entity_id}${params}`
)
);
});

for (const [idx, promise] of promises.entries()) {
let result: CalendarEvent[];
let result: CalendarEventApiData[];
try {
// eslint-disable-next-line no-await-in-loop
result = await promise;
Expand All @@ -87,53 +90,16 @@ export const fetchCalendarEvents = async (
}
const cal = calendars[idx];
result.forEach((ev) => {
const eventStart = getCalendarDate(ev.start);
const eventEnd = getCalendarDate(ev.end);
if (!eventStart || !eventEnd) {
return;
const normalized = normalizeSubscriptionEventData(ev, cal);
if (normalized) {
calEvents.push(normalized);
}
const eventData: CalendarEventData = {
uid: ev.uid,
summary: ev.summary,
description: ev.description,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,
rrule: ev.rrule,
};
const event: CalendarEvent = {
start: eventStart,
end: eventEnd,
title: ev.summary,
backgroundColor: cal.backgroundColor,
borderColor: cal.backgroundColor,
calendar: cal.entity_id,
eventData: eventData,
};

calEvents.push(event);
});
}

return { events: calEvents, errors };
};

const getCalendarDate = (dateObj: any): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}

if (dateObj.dateTime) {
return dateObj.dateTime;
}

if (dateObj.date) {
return dateObj.date;
}

return undefined;
};

export const getCalendars = (hass: HomeAssistant): Calendar[] =>
Object.keys(hass.states)
.filter(
Expand Down Expand Up @@ -191,3 +157,89 @@ export const deleteCalendarEvent = (
recurrence_id,
recurrence_range,
});

/**
* Calendar event data from both REST API and WebSocket subscription.
* Both APIs use the same data format.
*/
export interface CalendarEventApiData {
summary: string;
start: CalendarDateValue;
end: CalendarDateValue;
description?: string | null;
location?: string | null;
uid?: string | null;
recurrence_id?: string | null;
rrule?: string | null;
}

export interface CalendarEventSubscription {
events: CalendarEventApiData[] | null;
}

export const subscribeCalendarEvents = (
hass: HomeAssistant,
entity_id: string,
start: Date,
end: Date,
callback: (update: CalendarEventSubscription) => void
) =>
hass.connection.subscribeMessage<CalendarEventSubscription>(callback, {
type: "calendar/event/subscribe",
entity_id,
start: start.toISOString(),
end: end.toISOString(),
});

const getCalendarDate = (dateObj: CalendarDateValue): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}

if ("dateTime" in dateObj) {
return dateObj.dateTime;
}

if ("date" in dateObj) {
return dateObj.date;
}

return undefined;
};

/**
* Normalize calendar event data from API format to internal format.
* Handles both REST API format (with dateTime/date objects) and subscription format (strings).
* Converts to internal format with { dtstart, dtend, ... }
*/
export const normalizeSubscriptionEventData = (
eventData: CalendarEventApiData,
calendar: Calendar
): CalendarEvent | null => {
const eventStart = getCalendarDate(eventData.start);
const eventEnd = getCalendarDate(eventData.end);

if (!eventStart || !eventEnd) {
return null;
}

const normalizedEventData: CalendarEventData = {
summary: eventData.summary,
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
};

return {
start: eventStart,
end: eventEnd,
title: eventData.summary,
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
};
140 changes: 110 additions & 30 deletions src/panels/calendar/ha-panel-calendar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
Expand All @@ -20,8 +21,17 @@ import "../../components/ha-menu-button";
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 {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../data/calendar";
import {
getCalendars,
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../data/calendar";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { haStyle } from "../../resources/styles";
Expand All @@ -42,6 +52,8 @@ class PanelCalendar extends LitElement {

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

@state() private _errorCalendars: string[] = [];

@state()
@storage({
key: "deSelectedCalendars",
Expand All @@ -53,6 +65,8 @@ class PanelCalendar extends LitElement {

private _end?: Date;

private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};

private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
Expand All @@ -78,6 +92,7 @@ class PanelCalendar extends LitElement {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
this._unsubscribeAll();
}

private _setIsMobile = (ev: MediaQueryListEvent) => {
Expand Down Expand Up @@ -194,19 +209,95 @@ class PanelCalendar extends LitElement {
.map((cal) => cal);
}

private async _fetchEvents(
start: Date | undefined,
end: Date | undefined,
calendars: Calendar[]
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
if (!calendars.length || !start || !end) {
return { events: [], errors: [] };
private _subscribeCalendarEvents(calendars: Calendar[]): void {
if (!this._start || !this._end || calendars.length === 0) {
return;
}

return fetchCalendarEvents(this.hass, start, end, calendars);
this._error = undefined;

calendars.forEach((calendar) => {
// Unsubscribe existing subscription if any
if (calendar.entity_id in this._unsubs) {
this._unsubs[calendar.entity_id]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
}

const unsub = subscribeCalendarEvents(
this.hass,
calendar.entity_id,
this._start!,
this._end!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}

private async _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);

if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._handleErrors(this._errorCalendars);
return;
}

// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
this._handleErrors(this._errorCalendars);

// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);

this._events = [...this._events, ...newEvents];
}

private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}

private _unsubscribeCalendar(entityId: string): void {
if (entityId in this._unsubs) {
this._unsubs[entityId]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
delete this._unsubs[entityId];
}
}

private _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
ev.stopPropagation();
const entityId = (ev.target as HaListItem).value;
if (ev.detail.selected) {
Expand All @@ -223,13 +314,10 @@ class PanelCalendar extends LitElement {
if (!calendar) {
return;
}
const result = await this._fetchEvents(this._start, this._end, [
calendar,
]);
this._events = [...this._events, ...result.events];
this._handleErrors(result.errors);
this._subscribeCalendarEvents([calendar]);
} else {
this._deSelectedCalendars = [...this._deSelectedCalendars, entityId];
this._unsubscribeCalendar(entityId);
this._events = this._events.filter(
(event) => event.calendar !== entityId
);
Expand All @@ -254,23 +342,15 @@ class PanelCalendar extends LitElement {
): Promise<void> {
this._start = ev.detail.start;
this._end = ev.detail.end;
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}

private async _handleRefresh(): Promise<void> {
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}

private _handleErrors(error_entity_ids: string[]) {
Expand Down
Loading
Loading