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 }