From 4d160762aeef531208f8cf6094a0e58a9e7d53ec Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 20 Sep 2026 07:57:36 +0000 Subject: Add .ics calendar feed support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doot reads Google Calendar through a service account, by calendar ID. That cannot consume an .ics URL, and it cannot see a calendar subscribed to inside a personal Google account either, because Google does not allow re-sharing a subscribed calendar. Published schedules — sports fixtures, school terms, bin collections — are distributed almost exclusively as .ics, so none of them could be added at all. Feeds are now fetched on the normal calendar refresh and merged into calendar_events alongside Google events, so the timeline, widget and everything downstream keep seeing one list and needed no changes. Failure handling is deliberately asymmetric. A Google error is still returned as an error, unchanged, so an outage there surfaces. A single failing feed is recorded against that feed and skipped, because one dead URL should not take the whole calendar down — and last_error is shown in settings, since a feed that has quietly stopped resolving otherwise looks identical to a team with no games left. The parser handles what real feeds actually contain: line unfolding, CRLF and LF, UTC / TZID / VALUE=DATE times, and escaped text. Two details worth naming: a TZID time is wall-clock in that zone, and reading it as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local — wrong by the offset but entirely plausible-looking. And non-calendar content is a hard error rather than zero events, because an HTML error page from a moved feed URL is the common failure and "no events" reads as success. RRULE is deliberately unsupported: every schedule feed this targets expands recurrence itself, one VEVENT per fixture, and a correct expansion is a large amount of subtle code. A recurring event appears once rather than wrongly. Settings gains an add/enable/disable/remove UI. webcal:// links are accepted and rewritten, since that is how feeds are usually advertised. 16 parser tests, 5 store tests. Verified against the real Seattle Seahawks feed: 21 events, all with valid times. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- internal/handlers/handlers.go | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) (limited to 'internal/handlers/handlers.go') diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 147db07..caf7daa 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -325,9 +325,19 @@ func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.M return fetcher.FetchWithCache(ctx, forceRefresh) } -// fetchCalendarEvents fetches Google Calendar events from cache or API +// fetchCalendarEvents fetches calendar events from cache or source. +// +// Two sources feed the one cache: Google Calendar, and any subscribed .ics +// feeds. They are merged here rather than downstream so the timeline, the +// widget and everything else keep seeing a single list of calendar events. +// +// A Google failure is still returned as an error, unchanged — an outage there +// should surface rather than be masked by a partially-populated calendar. A +// single failing .ics feed, by contrast, is recorded against that feed and +// skipped, because one dead feed URL should not take the calendar down. func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([]models.CalendarEvent, error) { - if h.googleCalendarClient == nil { + icsFeeds, _ := h.store.GetEnabledICSFeeds() + if h.googleCalendarClient == nil && len(icsFeeds) == 0 { return nil, nil } @@ -355,20 +365,30 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ enabledIDs = append(enabledIDs, trimmed) } } - } else { - // Configs exist but all disabled — respect that + } else if len(icsFeeds) == 0 { + // Configs exist but all disabled, and no .ics feeds either. return nil, nil } } - h.googleCalendarClient.SetCalendarIDs(enabledIDs) + if h.googleCalendarClient != nil { + h.googleCalendarClient.SetCalendarIDs(enabledIDs) + } fetcher := &CacheFetcher[models.CalendarEvent]{ Store: h.store, CacheKey: store.CacheKeyGoogleCalendar, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { - return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + var events []models.CalendarEvent + if h.googleCalendarClient != nil && len(enabledIDs) > 0 { + googleEvents, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + if err != nil { + return nil, err + } + events = append(events, googleEvents...) + } + return append(events, h.fetchICSFeedEvents(ctx)...), nil }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, -- cgit v1.2.3