mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-18 15:30:24 +00:00
Compare commits
16 Commits
add-device
...
calendar-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882682d0ae | ||
|
|
ec22937e71 | ||
|
|
53a87278dd | ||
|
|
31383c114b | ||
|
|
7e01777eaa | ||
|
|
9c64f5ac8b | ||
|
|
912e636207 | ||
|
|
43367350b7 | ||
|
|
64a25cf7f9 | ||
|
|
6de8f47e24 | ||
|
|
0427c17a76 | ||
|
|
0052f14521 | ||
|
|
792274a82a | ||
|
|
7b42b16de8 | ||
|
|
fe98c0bdc0 | ||
|
|
658955a1b9 |
@@ -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,
|
||||
@@ -65,11 +68,11 @@ 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}`
|
||||
)
|
||||
@@ -77,7 +80,7 @@ export const fetchCalendarEvents = async (
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -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(
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -42,6 +52,8 @@ class PanelCalendar extends LitElement {
|
||||
|
||||
@state() private _error?: string = undefined;
|
||||
|
||||
@state() private _errorCalendars: string[] = [];
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "deSelectedCalendars",
|
||||
@@ -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,
|
||||
});
|
||||
@@ -78,6 +92,7 @@ class PanelCalendar extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
this._mql?.removeListener(this._setIsMobile!);
|
||||
this._mql = undefined;
|
||||
this._unsubscribeAll();
|
||||
}
|
||||
|
||||
private _setIsMobile = (ev: MediaQueryListEvent) => {
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
);
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -7,8 +8,16 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-card";
|
||||
import type { Calendar, CalendarEvent } from "../../../data/calendar";
|
||||
import { fetchCalendarEvents } from "../../../data/calendar";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarEventSubscription,
|
||||
CalendarEventApiData,
|
||||
} from "../../../data/calendar";
|
||||
import {
|
||||
normalizeSubscriptionEventData,
|
||||
subscribeCalendarEvents,
|
||||
} from "../../../data/calendar";
|
||||
import type {
|
||||
CalendarViewChanged,
|
||||
FullCalendarView,
|
||||
@@ -65,12 +74,16 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _error?: string = undefined;
|
||||
|
||||
@state() private _errorCalendars: string[] = [];
|
||||
|
||||
private _startDate?: Date;
|
||||
|
||||
private _endDate?: Date;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
|
||||
|
||||
public setConfig(config: CalendarCardConfig): void {
|
||||
if (!config.entities?.length) {
|
||||
throw new Error("Entities must be specified");
|
||||
@@ -86,7 +99,8 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
}));
|
||||
|
||||
if (this._config?.entities !== config.entities) {
|
||||
this._fetchCalendarEvents();
|
||||
this._unsubscribeAll();
|
||||
// Subscription will happen when view-changed event fires
|
||||
}
|
||||
|
||||
this._config = { initial_view: "dayGridMonth", ...config };
|
||||
@@ -115,6 +129,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
this._unsubscribeAll();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -170,31 +185,85 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleViewChanged(ev: HASSDomEvent<CalendarViewChanged>): void {
|
||||
private async _handleViewChanged(
|
||||
ev: HASSDomEvent<CalendarViewChanged>
|
||||
): Promise<void> {
|
||||
this._startDate = ev.detail.start;
|
||||
this._endDate = ev.detail.end;
|
||||
this._fetchCalendarEvents();
|
||||
await this._unsubscribeAll();
|
||||
this._subscribeCalendarEvents();
|
||||
}
|
||||
|
||||
private async _fetchCalendarEvents(): Promise<void> {
|
||||
if (!this._startDate || !this._endDate) {
|
||||
private _subscribeCalendarEvents(): void {
|
||||
if (!this.hass || !this._startDate || !this._endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = undefined;
|
||||
const result = await fetchCalendarEvents(
|
||||
this.hass!,
|
||||
this._startDate,
|
||||
this._endDate,
|
||||
this._calendars
|
||||
);
|
||||
this._events = result.events;
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
this._calendars.forEach((calendar) => {
|
||||
const unsub = subscribeCalendarEvents(
|
||||
this.hass!,
|
||||
calendar.entity_id,
|
||||
this._startDate!,
|
||||
this._endDate!,
|
||||
(update: CalendarEventSubscription) => {
|
||||
this._handleCalendarUpdate(calendar, update);
|
||||
}
|
||||
);
|
||||
this._unsubs[calendar.entity_id] = unsub;
|
||||
});
|
||||
}
|
||||
|
||||
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._error = `${this.hass!.localize(
|
||||
"ui.components.calendar.event_retrieval_error"
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from error list if successfully loaded
|
||||
this._errorCalendars = this._errorCalendars.filter(
|
||||
(id) => id !== calendar.entity_id
|
||||
);
|
||||
if (this._errorCalendars.length === 0) {
|
||||
this._error = undefined;
|
||||
}
|
||||
|
||||
// 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 _measureCard() {
|
||||
|
||||
Reference in New Issue
Block a user