summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-20 07:57:36 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-20 07:57:36 +0000
commit4d160762aeef531208f8cf6094a0e58a9e7d53ec (patch)
tree79584994808a38a2518ad986dc739daf720d3d77 /internal/handlers
parentc5ab2f9a7f6a0b1e456e13934268d1509504c55a (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.go32
-rw-r--r--internal/handlers/ics.go180
-rw-r--r--internal/handlers/settings.go3
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"),
}