import { useCallback, useEffect, useRef, useState } from 'react' import type { AxiosError } from 'axios' import ICAL from 'ical.js' import { calendarApi } from '../../api' import type { WidgetConfig } from '../../hooks' import './widget-styles.css' const REFRESH_INTERVAL_MS = 5 * 60 * 1000 const MAX_EVENTS = 25 interface CalendarWidgetProps { config: WidgetConfig } interface AgendaEvent { id: string summary: string start: Date end: Date location?: string | null description?: string | null isAllDay: boolean } const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', }) const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', }) const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) function formatDayLabel(date: Date) { const today = new Date() const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()) const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) const diffMs = startOfDate.getTime() - startOfToday.getTime() const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000)) if (Math.abs(diffDays) <= 1) { return relativeFormatter.format(diffDays, 'day') } return dayFormatter.format(date) } function formatEventRange(event: AgendaEvent) { const sameDay = event.start.toDateString() === event.end.toDateString() const dayLabel = formatDayLabel(event.start) if (event.isAllDay) { return `${dayLabel} - All day` } if (sameDay) { return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}` } return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}` } function createAgendaEvents( component: ICAL.Component, windowStart: Date, windowEnd: Date, ): AgendaEvent[] { const event = new ICAL.Event(component) const results: AgendaEvent[] = [] const eventTemplate = { summary: event.summary || 'Untitled event', location: event.location || null, description: event.description || null, } const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => { const jsStart = start.toJSDate() const jsEnd = (end || start).toJSDate() if (jsEnd < windowStart || jsStart > windowEnd) { return } results.push({ id: `${event.uid || event.summary}-${jsStart.toISOString()}`, summary: eventTemplate.summary, location: eventTemplate.location, description: eventTemplate.description, start: jsStart, end: jsEnd, isAllDay: start.isDate, }) } const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000) const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart) if (event.isRecurring()) { const iterator = event.iterator(iteratorStart) let next = iterator.next() while (next) { const occurrence = event.getOccurrenceDetails(next) addOccurrence(occurrence.startDate, occurrence.endDate) const jsStart = occurrence.startDate.toJSDate() if (jsStart > windowEnd) { break } next = iterator.next() } } else { addOccurrence(event.startDate, event.endDate) } return results } export default function CalendarWidget({ config }: CalendarWidgetProps) { const calendarConfig = config.calendar const [events, setEvents] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) const abortRef = useRef(null) const requestIdRef = useRef(0) const fetchEvents = useCallback(async () => { if (!calendarConfig?.icalUrl) { setEvents([]) setError('Calendar URL is missing') return } abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller const requestId = requestIdRef.current + 1 requestIdRef.current = requestId setIsLoading(true) setError(null) try { const response = await calendarApi.fetch(calendarConfig.icalUrl, { signal: controller.signal, }) const raw = response.data?.calendar_data if (!raw) { throw new Error('Calendar feed was empty') } const parsed = ICAL.parse(raw) const comp = new ICAL.Component(parsed) const vevents = comp.getAllSubcomponents('vevent') || [] const now = new Date() const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000) const agendaEvents = vevents .flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd)) .sort((a, b) => a.start.getTime() - b.start.getTime()) .slice(0, MAX_EVENTS) setEvents(agendaEvents) setLastUpdated(new Date()) } catch (err) { if (controller.signal.aborted) { return } const axiosError = err as AxiosError<{ error?: string; detail?: string }> const message = axiosError?.response?.data?.error || axiosError?.response?.data?.detail || axiosError?.message || 'Failed to load calendar' setError(message) setEvents([]) } finally { if (!controller.signal.aborted && requestId === requestIdRef.current) { setIsLoading(false) } } }, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours]) useEffect(() => { if (!calendarConfig?.icalUrl) { return } fetchEvents() const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS) return () => { clearInterval(interval) abortRef.current?.abort() } }, [calendarConfig?.icalUrl, fetchEvents]) const handleManualRefresh = () => { fetchEvents() } if (!calendarConfig) { return (

{config.title || 'Calendar'}

Configure an iCal URL to see your agenda.

) } const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h` return (

Agenda

{config.title || 'Calendar'}

{rangeLabel}

{lastUpdated && ( Updated {timeFormatter.format(lastUpdated)} )}
{error && (
{error}
)} {!error && events.length === 0 && !isLoading && (
No upcoming events in this window.
)} {isLoading && events.length === 0 && (
)} {events.length > 0 && (
    {events.map((event) => (
  • {event.summary || 'Untitled event'}

    {formatEventRange(event)}

    {event.location && (

    {event.location}

    )}
    {event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
  • ))}
)}
) }