diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 07:57:36 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 07:57:36 +0000 |
| commit | 4d160762aeef531208f8cf6094a0e58a9e7d53ec (patch) | |
| tree | 79584994808a38a2518ad986dc739daf720d3d77 /internal/handlers | |
| parent | c5ab2f9a7f6a0b1e456e13934268d1509504c55a (diff) | |
Add .ics calendar feed support
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/handlers.go | 32 | ||||
| -rw-r--r-- | internal/handlers/ics.go | 180 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 3 |
3 files changed, 209 insertions, 6 deletions
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, diff --git a/internal/handlers/ics.go b/internal/handlers/ics.go new file mode 100644 index 0000000..e0f96de --- /dev/null +++ b/internal/handlers/ics.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "context" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "task-dashboard/internal/api" + "task-dashboard/internal/config" + "task-dashboard/internal/models" + "task-dashboard/internal/store" +) + +// fetchICSFeedEvents fetches every enabled .ics feed and returns their events. +// +// Failures are per-feed: one unreachable URL is recorded against that feed and +// skipped, leaving the others (and Google Calendar) unaffected. The whole point +// of recording last_error is that a feed which has quietly stopped resolving +// shows as broken in settings, instead of just silently having no events — +// which is indistinguishable from a team having no games left. +func (h *Handler) fetchICSFeedEvents(ctx context.Context) []models.CalendarEvent { + feeds, err := h.store.GetEnabledICSFeeds() + if err != nil { + log.Printf("ERROR [ICS]: listing feeds: %v", err) + return nil + } + if len(feeds) == 0 { + return nil + } + + fetcher := api.NewICSFetcher(config.HTTPClientTimeout) + + var all []models.CalendarEvent + for _, feed := range feeds { + events, err := fetcher.Fetch(ctx, feed.URL) + if err != nil { + log.Printf("ERROR [ICS/%s]: %v", feed.Name, err) + _ = h.store.RecordICSFeedResult(feed.ID, 0, err) + continue + } + converted := api.ToCalendarEvents(strconv.FormatInt(feed.ID, 10), feed.Name, events) + all = append(all, converted...) + _ = h.store.RecordICSFeedResult(feed.ID, len(converted), nil) + log.Printf("DEBUG [ICS/%s]: %d events", feed.Name, len(converted)) + } + return all +} + +// HandleAddICSFeed subscribes to a new .ics feed. +func (h *Handler) HandleAddICSFeed(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + feedURL := strings.TrimSpace(r.FormValue("url")) + + // webcal:// is how calendar feeds are usually advertised; it is plain HTTPS + // underneath, and pasting the advertised link is the obvious thing to do. + if strings.HasPrefix(strings.ToLower(feedURL), "webcal://") { + feedURL = "https://" + feedURL[len("webcal://"):] + } + + if name == "" || feedURL == "" { + JSONError(w, http.StatusBadRequest, "Name and URL are required", nil) + return + } + parsed, err := url.Parse(feedURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + JSONError(w, http.StatusBadRequest, "URL must be a valid http(s) or webcal address", nil) + return + } + + if err := h.store.AddICSFeed(name, feedURL); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to save feed", err) + return + } + + // Drop the cached calendar so the new feed's events appear on the next + // load rather than after the TTL expires. + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + + h.renderICSFeedList(w, r) +} + +// HandleToggleICSFeed enables or disables a feed without deleting it. +func (h *Handler) HandleToggleICSFeed(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + JSONError(w, http.StatusBadRequest, "Invalid feed id", err) + return + } + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + enabled := r.FormValue("enabled") == "true" + + if err := h.store.SetICSFeedEnabled(id, enabled); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to update feed", err) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + h.renderICSFeedList(w, r) +} + +// HandleDeleteICSFeed removes a feed. +func (h *Handler) HandleDeleteICSFeed(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + JSONError(w, http.StatusBadRequest, "Invalid feed id", err) + return + } + if err := h.store.DeleteICSFeed(id); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to delete feed", err) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + h.renderICSFeedList(w, r) +} + +// HandleGetICSFeeds renders the feed list on its own, for the settings page. +func (h *Handler) HandleGetICSFeeds(w http.ResponseWriter, r *http.Request) { + h.renderICSFeedList(w, r) +} + +func (h *Handler) renderICSFeedList(w http.ResponseWriter, r *http.Request) { + feeds, err := h.store.GetICSFeeds() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to list feeds", err) + return + } + HTMLResponse(w, h.renderer, "ics-feed-list", struct { + Feeds []icsFeedView + }{toICSFeedViews(feeds)}) +} + +// icsFeedView is the feed as the settings template needs it: pre-formatted, so +// the template holds no date or error-shortening logic. +type icsFeedView struct { + ID int64 + Name string + URL string + Enabled bool + LastFetched string + LastError string + EventCount int +} + +func toICSFeedViews(feeds []store.ICSFeed) []icsFeedView { + out := make([]icsFeedView, 0, len(feeds)) + for _, f := range feeds { + lastFetched := "never" + if f.LastFetchedAt != nil { + lastFetched = config.ToDisplayTZ(*f.LastFetchedAt).Format("Jan 2, 3:04 PM") + } + // A raw Go error can be a paragraph of URL and wrapping; the settings + // row needs enough to recognise the problem, not the whole thing. + lastError := f.LastError + if len(lastError) > 160 { + lastError = lastError[:157] + "..." + } + out = append(out, icsFeedView{ + ID: f.ID, + Name: f.Name, + URL: f.URL, + Enabled: f.Enabled, + LastFetched: lastFetched, + LastError: lastError, + EventCount: f.EventCount, + }) + } + return out +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index d140aaf..d6d78c0 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -23,6 +23,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { } googleTasksToken, _ := h.store.GetOAuthToken(api.GoogleTasksOAuthSource) + icsFeeds, _ := h.store.GetICSFeeds() data := struct { Configs map[string][]models.SourceConfig @@ -34,6 +35,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { GoogleTasksOAuthReady bool GoogleTasksConnected bool GoogleTasksError string + Feeds []icsFeedView }{ Configs: bySource, Sources: []string{"trello", "gcal", "gtasks"}, @@ -43,6 +45,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { WebAuthnEnabled: h.WebAuthnEnabled, GoogleTasksOAuthReady: h.googleTasksOAuthConfig != nil, GoogleTasksConnected: googleTasksToken != nil, + Feeds: toICSFeedViews(icsFeeds), GoogleTasksError: r.URL.Query().Get("google_tasks_error"), } |
