summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/ics.go249
-rw-r--r--internal/api/ics_client.go92
-rw-r--r--internal/api/ics_test.go200
-rw-r--r--internal/handlers/handlers.go32
-rw-r--r--internal/handlers/ics.go180
-rw-r--r--internal/handlers/settings.go3
-rw-r--r--internal/store/ics_feeds.go95
-rw-r--r--internal/store/ics_feeds_test.go144
8 files changed, 989 insertions, 6 deletions
diff --git a/internal/api/ics.go b/internal/api/ics.go
new file mode 100644
index 0000000..925efc7
--- /dev/null
+++ b/internal/api/ics.go
@@ -0,0 +1,249 @@
+package api
+
+import (
+ "bufio"
+ "crypto/sha1"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+)
+
+// ICSEvent is a single VEVENT, reduced to the fields doot shows.
+type ICSEvent struct {
+ UID string
+ Summary string
+ Description string
+ Location string
+ Start time.Time
+ End time.Time
+ AllDay bool
+}
+
+// StableID returns an id that stays the same across refreshes, scoped to the
+// feed it came from.
+//
+// Scoping matters: two feeds can legitimately contain the same UID (the same
+// fixture published by two aggregators), and an unscoped id would make one
+// silently overwrite the other. Feeds that omit UID entirely fall back to a
+// hash of the event's own content, which is stable as long as the event is.
+func (e ICSEvent) StableID(feedID string) string {
+ key := e.UID
+ if key == "" {
+ key = fmt.Sprintf("%s|%s|%s", e.Summary, e.Start.UTC().Format(time.RFC3339), e.Location)
+ }
+ sum := sha1.Sum([]byte(feedID + "|" + key))
+ return "ics-" + hex.EncodeToString(sum[:])[:20]
+}
+
+// ParseICS reads an iCalendar stream and returns its VEVENTs.
+//
+// Deliberately not supported: RRULE. Recurring events are expanded by the
+// publisher in every schedule feed this targets — each fixture is its own
+// VEVENT — and a correct RRULE expansion (COUNT/UNTIL/BYDAY/EXDATE, across DST
+// boundaries) is a large amount of subtle code. A recurring event will appear
+// once, at its first occurrence, rather than silently wrongly.
+func ParseICS(r io.Reader) ([]ICSEvent, error) {
+ lines, err := unfold(r)
+ if err != nil {
+ return nil, err
+ }
+
+ sawCalendar := false
+ for _, l := range lines {
+ if strings.EqualFold(strings.TrimSpace(l), "BEGIN:VCALENDAR") {
+ sawCalendar = true
+ break
+ }
+ }
+ if !sawCalendar {
+ // Usually an HTML error page from a feed URL that has moved or died.
+ // Returning zero events would look like "the team has no games".
+ return nil, errors.New("not an iCalendar document (no BEGIN:VCALENDAR)")
+ }
+
+ var events []ICSEvent
+ var cur *ICSEvent
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ switch {
+ case strings.EqualFold(trimmed, "BEGIN:VEVENT"):
+ cur = &ICSEvent{}
+ continue
+ case strings.EqualFold(trimmed, "END:VEVENT"):
+ if cur != nil {
+ if !cur.Start.IsZero() {
+ if cur.End.IsZero() {
+ cur.End = defaultEnd(*cur)
+ }
+ events = append(events, *cur)
+ }
+ cur = nil
+ }
+ continue
+ }
+ if cur == nil {
+ continue // outside a VEVENT (VCALENDAR headers, VTIMEZONE, VALARM)
+ }
+
+ name, params, value := splitProperty(line)
+ switch strings.ToUpper(name) {
+ case "UID":
+ cur.UID = value
+ case "SUMMARY":
+ cur.Summary = unescapeText(value)
+ case "DESCRIPTION":
+ cur.Description = unescapeText(value)
+ case "LOCATION":
+ cur.Location = unescapeText(value)
+ case "DTSTART":
+ if t, allDay, ok := parseICSTime(value, params); ok {
+ cur.Start, cur.AllDay = t, allDay
+ }
+ case "DTEND":
+ if t, _, ok := parseICSTime(value, params); ok {
+ cur.End = t
+ }
+ }
+ }
+ return events, nil
+}
+
+// defaultEnd supplies an end for an event that gave none. A zero end would
+// render as an event running from its start to year zero.
+func defaultEnd(e ICSEvent) time.Time {
+ if e.AllDay {
+ return e.Start.AddDate(0, 0, 1)
+ }
+ return e.Start.Add(time.Hour)
+}
+
+// unfold reads the stream and joins continuation lines. Per RFC 5545 a line
+// beginning with a space or tab continues the previous one; splitting a long
+// SUMMARY is routine, so not unfolding truncates titles mid-word.
+func unfold(r io.Reader) ([]string, error) {
+ scanner := bufio.NewScanner(r)
+ // Feeds with long DESCRIPTIONs exceed bufio's default 64KB line limit.
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+
+ var out []string
+ for scanner.Scan() {
+ line := strings.TrimRight(scanner.Text(), "\r")
+ if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
+ out[len(out)-1] += line[1:]
+ continue
+ }
+ out = append(out, line)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// splitProperty divides "NAME;PARAM=VAL;P2=V2:value" into its parts. The colon
+// inside a value (a URL, a time) must not be treated as the separator, so only
+// the first colon outside a quoted parameter counts.
+func splitProperty(line string) (name string, params map[string]string, value string) {
+ params = map[string]string{}
+
+ inQuotes := false
+ colon := -1
+ for i, r := range line {
+ if r == '"' {
+ inQuotes = !inQuotes
+ }
+ if r == ':' && !inQuotes {
+ colon = i
+ break
+ }
+ }
+ if colon == -1 {
+ return line, params, ""
+ }
+
+ head := line[:colon]
+ value = line[colon+1:]
+
+ parts := strings.Split(head, ";")
+ name = parts[0]
+ for _, p := range parts[1:] {
+ if eq := strings.Index(p, "="); eq > 0 {
+ k := strings.ToUpper(strings.TrimSpace(p[:eq]))
+ v := strings.Trim(strings.TrimSpace(p[eq+1:]), `"`)
+ params[k] = v
+ }
+ }
+ return name, params, value
+}
+
+// parseICSTime handles the three forms that appear in practice:
+//
+// 20260913T201500Z UTC
+// 20260920T131500 with TZID=Zone wall-clock time in that zone
+// 20260913 with VALUE=DATE all-day
+//
+// The TZID case is the one worth care: those digits are local to the named
+// zone, and reading them as UTC shifts the event by the zone's offset — wrong
+// by hours, but plausible-looking, so easy to miss.
+func parseICSTime(value string, params map[string]string) (t time.Time, allDay bool, ok bool) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return time.Time{}, false, false
+ }
+
+ if strings.EqualFold(params["VALUE"], "DATE") || len(value) == 8 {
+ parsed, err := time.ParseInLocation("20060102", value, time.UTC)
+ if err != nil {
+ return time.Time{}, false, false
+ }
+ return parsed, true, true
+ }
+
+ if strings.HasSuffix(value, "Z") {
+ parsed, err := time.ParseInLocation("20060102T150405Z", value, time.UTC)
+ if err != nil {
+ return time.Time{}, false, false
+ }
+ return parsed, false, true
+ }
+
+ loc := time.UTC
+ if tzid := params["TZID"]; tzid != "" {
+ if l, err := time.LoadLocation(tzid); err == nil {
+ loc = l
+ }
+ // An unknown TZID falls back to UTC rather than dropping the event;
+ // a visible event at the wrong hour beats a missing one.
+ }
+ parsed, err := time.ParseInLocation("20060102T150405", value, loc)
+ if err != nil {
+ return time.Time{}, false, false
+ }
+ return parsed, false, true
+}
+
+// unescapeText reverses RFC 5545 text escaping.
+func unescapeText(s string) string {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] != '\\' || i+1 >= len(s) {
+ b.WriteByte(s[i])
+ continue
+ }
+ i++
+ switch s[i] {
+ case 'n', 'N':
+ b.WriteByte('\n')
+ case '\\', ',', ';':
+ b.WriteByte(s[i])
+ default:
+ // Unknown escape: keep both characters rather than eat them.
+ b.WriteByte('\\')
+ b.WriteByte(s[i])
+ }
+ }
+ return b.String()
+}
diff --git a/internal/api/ics_client.go b/internal/api/ics_client.go
new file mode 100644
index 0000000..857fa8f
--- /dev/null
+++ b/internal/api/ics_client.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// ICSFetcher retrieves and parses a remote iCalendar feed.
+type ICSFetcher struct {
+ client *http.Client
+}
+
+// NewICSFetcher builds a fetcher with a bounded timeout.
+func NewICSFetcher(timeout time.Duration) *ICSFetcher {
+ return &ICSFetcher{client: &http.Client{Timeout: timeout}}
+}
+
+// maxICSBytes caps how much of a feed is read. A calendar is small; anything
+// enormous is a misconfigured URL pointing at something else, and reading it
+// all into memory would be the only harm it does.
+const maxICSBytes = 8 << 20 // 8 MiB
+
+// Fetch downloads the feed at url and returns its events.
+//
+// The context is threaded into the request, so a hung feed cannot outlive the
+// refresh that started it — see internal/api/context_audit_test.go for why that
+// matters here.
+func (f *ICSFetcher) Fetch(ctx context.Context, url string) ([]ICSEvent, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("building request: %w", err)
+ }
+ // Some publishers serve HTML to unknown agents and the calendar only to
+ // something that looks like a calendar client.
+ req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.8")
+ req.Header.Set("User-Agent", "doot/1.0 (+https://doot.terst.org)")
+
+ resp, err := f.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetching feed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("feed returned HTTP %d", resp.StatusCode)
+ }
+
+ events, err := ParseICS(io.LimitReader(resp.Body, maxICSBytes))
+ if err != nil {
+ return nil, fmt.Errorf("parsing feed: %w", err)
+ }
+ return events, nil
+}
+
+// ToCalendarEvents converts parsed ICS events into the model the timeline
+// already renders, so feed events need no special handling downstream.
+//
+// feedID scopes the generated ids; feedName is prefixed to each title so a
+// bare fixture like "Seahawks at 49ers" is identifiable on a mixed timeline.
+func ToCalendarEvents(feedID, feedName string, events []ICSEvent) []models.CalendarEvent {
+ out := make([]models.CalendarEvent, 0, len(events))
+ for _, e := range events {
+ summary := e.Summary
+ if summary == "" {
+ summary = feedName
+ }
+ out = append(out, models.CalendarEvent{
+ ID: e.StableID(feedID),
+ Summary: summary,
+ Description: descriptionWithLocation(e),
+ Start: e.Start,
+ End: e.End,
+ })
+ }
+ return out
+}
+
+func descriptionWithLocation(e ICSEvent) string {
+ switch {
+ case e.Location == "":
+ return e.Description
+ case e.Description == "":
+ return e.Location
+ default:
+ return e.Description + "\n" + e.Location
+ }
+}
diff --git a/internal/api/ics_test.go b/internal/api/ics_test.go
new file mode 100644
index 0000000..a4a3474
--- /dev/null
+++ b/internal/api/ics_test.go
@@ -0,0 +1,200 @@
+package api
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// A realistic feed: UTC stamps, a TZID-qualified local time, an all-day event,
+// folded lines, escaped text, and CRLF endings — all of which appear in the
+// sports-schedule feeds this was written for.
+const sampleICS = "BEGIN:VCALENDAR\r\n" +
+ "VERSION:2.0\r\n" +
+ "PRODID:-//Test//EN\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:game-1@example.com\r\n" +
+ "DTSTART:20260913T201500Z\r\n" +
+ "DTEND:20260913T231500Z\r\n" +
+ "SUMMARY:Seahawks at 49ers\r\n" +
+ "LOCATION:Levi's Stadium\r\n" +
+ "END:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:game-2@example.com\r\n" +
+ "DTSTART;TZID=America/Los_Angeles:20260920T131500\r\n" +
+ "DTEND;TZID=America/Los_Angeles:20260920T161500\r\n" +
+ "SUMMARY:Seahawks vs. Saints\r\n" +
+ "DESCRIPTION:Home opener\\, kickoff 1:15pm\\nGates open at 11am\r\n" +
+ "END:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:bye-week@example.com\r\n" +
+ "DTSTART;VALUE=DATE:20261012\r\n" +
+ "DTEND;VALUE=DATE:20261013\r\n" +
+ "SUMMARY:Bye week\r\n" +
+ "END:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:folded@example.com\r\n" +
+ "DTSTART:20261019T170000Z\r\n" +
+ "DTEND:20261019T200000Z\r\n" +
+ // Folded mid-word on purpose: unfolding removes the CRLF and exactly one
+ // leading space, so "...that the fe" + " " + "ed has..." must rejoin as
+ // "that the feed has...". Folding at a word boundary would not distinguish
+ // a correct implementation from one that also eats a real space.
+ "SUMMARY:A very long game title that the fe\r\n" +
+ " ed has folded across two lines\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+
+func parseOrFail(t *testing.T, body string) []ICSEvent {
+ t.Helper()
+ events, err := ParseICS(strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("ParseICS: %v", err)
+ }
+ return events
+}
+
+func TestParseICS_ReadsAllEvents(t *testing.T) {
+ events := parseOrFail(t, sampleICS)
+ if len(events) != 4 {
+ t.Fatalf("got %d events, want 4: %+v", len(events), events)
+ }
+}
+
+func TestParseICS_UTCTimestamps(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[0]
+ if e.UID != "game-1@example.com" {
+ t.Errorf("UID = %q", e.UID)
+ }
+ if e.Summary != "Seahawks at 49ers" {
+ t.Errorf("Summary = %q", e.Summary)
+ }
+ want := time.Date(2026, 9, 13, 20, 15, 0, 0, time.UTC)
+ if !e.Start.Equal(want) {
+ t.Errorf("Start = %v, want %v", e.Start.UTC(), want)
+ }
+ if !e.End.Equal(time.Date(2026, 9, 13, 23, 15, 0, 0, time.UTC)) {
+ t.Errorf("End = %v", e.End.UTC())
+ }
+ if e.AllDay {
+ t.Error("a timed event was marked all-day")
+ }
+}
+
+// A TZID-qualified time is a wall-clock time in that zone, not UTC. Reading it
+// as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local —
+// silently, and only wrong by the offset.
+func TestParseICS_TZIDIsInterpretedInThatZone(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[1]
+ la, err := time.LoadLocation("America/Los_Angeles")
+ if err != nil {
+ t.Skipf("tzdata unavailable: %v", err)
+ }
+ want := time.Date(2026, 9, 20, 13, 15, 0, 0, la)
+ if !e.Start.Equal(want) {
+ t.Errorf("Start = %v (%v), want %v", e.Start, e.Start.UTC(), want.UTC())
+ }
+}
+
+func TestParseICS_AllDayEvent(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[2]
+ if !e.AllDay {
+ t.Error("VALUE=DATE event should be all-day")
+ }
+ if e.Start.Year() != 2026 || e.Start.Month() != time.October || e.Start.Day() != 12 {
+ t.Errorf("Start = %v, want 2026-10-12", e.Start)
+ }
+}
+
+// Folded lines: a leading space or tab continues the previous line. Not
+// unfolding leaves the title truncated at the fold.
+func TestParseICS_UnfoldsLines(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[3]
+ want := "A very long game title that the feed has folded across two lines"
+ if e.Summary != want {
+ t.Errorf("Summary = %q, want %q", e.Summary, want)
+ }
+}
+
+// Text values escape commas, semicolons, backslashes and newlines.
+func TestParseICS_UnescapesText(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[1]
+ want := "Home opener, kickoff 1:15pm\nGates open at 11am"
+ if e.Description != want {
+ t.Errorf("Description = %q, want %q", e.Description, want)
+ }
+}
+
+func TestParseICS_LocationIsCaptured(t *testing.T) {
+ if got := parseOrFail(t, sampleICS)[0].Location; got != "Levi's Stadium" {
+ t.Errorf("Location = %q", got)
+ }
+}
+
+// An event with no DTEND gets a sensible end rather than a zero time, which
+// would render as a 56-year-long event.
+func TestParseICS_MissingDTENDDefaults(t *testing.T) {
+ body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No end\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
+ e := parseOrFail(t, body)[0]
+ if !e.End.After(e.Start) {
+ t.Errorf("End %v should be after Start %v", e.End, e.Start)
+ }
+}
+
+// Events with no usable start are skipped rather than landing at the zero time,
+// where they would sort to the top of the timeline forever.
+func TestParseICS_SkipsEventsWithoutAStart(t *testing.T) {
+ body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\nUID:y\r\nDTSTART:20260913T201500Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
+ events := parseOrFail(t, body)
+ if len(events) != 1 || events[0].Summary != "Fine" {
+ t.Errorf("got %+v, want only the event with a start", events)
+ }
+}
+
+func TestParseICS_HandlesLFOnlyLineEndings(t *testing.T) {
+ body := strings.ReplaceAll(sampleICS, "\r\n", "\n")
+ if got := len(parseOrFail(t, body)); got != 4 {
+ t.Errorf("got %d events with LF endings, want 4", got)
+ }
+}
+
+// Anything that is not a calendar must be a clear error, not silently zero
+// events — an HTML error page from a dead feed URL is the common case.
+func TestParseICS_RejectsNonCalendarContent(t *testing.T) {
+ if _, err := ParseICS(strings.NewReader("<html><body>404 Not Found</body></html>")); err == nil {
+ t.Error("expected an error for non-calendar content")
+ }
+}
+
+func TestParseICS_EmptyCalendarIsNotAnError(t *testing.T) {
+ events, err := ParseICS(strings.NewReader("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(events) != 0 {
+ t.Errorf("got %d events, want 0", len(events))
+ }
+}
+
+// A feed with no UID still needs a stable id, or every refresh would look like
+// a brand-new set of events.
+func TestICSEvent_StableIDWithoutUID(t *testing.T) {
+ body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
+ first := parseOrFail(t, body)[0].StableID("feed-1")
+ second := parseOrFail(t, body)[0].StableID("feed-1")
+ if first == "" {
+ t.Fatal("StableID returned empty")
+ }
+ if first != second {
+ t.Errorf("StableID is not stable: %q vs %q", first, second)
+ }
+}
+
+// Two feeds containing the same UID must not collide into one event.
+func TestICSEvent_StableIDIsScopedToItsFeed(t *testing.T) {
+ e := parseOrFail(t, sampleICS)[0]
+ if e.StableID("feed-1") == e.StableID("feed-2") {
+ t.Error("same UID in different feeds produced the same id")
+ }
+}
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"),
}
diff --git a/internal/store/ics_feeds.go b/internal/store/ics_feeds.go
new file mode 100644
index 0000000..6495e13
--- /dev/null
+++ b/internal/store/ics_feeds.go
@@ -0,0 +1,95 @@
+package store
+
+import (
+ "database/sql"
+ "time"
+)
+
+// ICSFeed is a subscribed iCalendar feed.
+type ICSFeed struct {
+ ID int64
+ Name string
+ URL string
+ Enabled bool
+ LastFetchedAt *time.Time
+ LastError string
+ EventCount int
+ CreatedAt time.Time
+}
+
+// AddICSFeed stores a feed. Re-adding an existing URL updates its name and
+// re-enables it rather than failing on the unique constraint — "add it again"
+// is the obvious way to try to fix a feed that was turned off.
+func (s *Store) AddICSFeed(name, url string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO ics_feeds (name, url, enabled)
+ VALUES (?, ?, 1)
+ ON CONFLICT(url) DO UPDATE SET name = excluded.name, enabled = 1
+ `, name, url)
+ return err
+}
+
+// GetICSFeeds returns every feed, enabled or not, newest name-order first.
+func (s *Store) GetICSFeeds() ([]ICSFeed, error) {
+ return s.queryICSFeeds(`
+ SELECT id, name, url, enabled, last_fetched_at, COALESCE(last_error, ''), event_count, created_at
+ FROM ics_feeds ORDER BY name`)
+}
+
+// GetEnabledICSFeeds returns only the feeds that should be fetched.
+func (s *Store) GetEnabledICSFeeds() ([]ICSFeed, error) {
+ return s.queryICSFeeds(`
+ SELECT id, name, url, enabled, last_fetched_at, COALESCE(last_error, ''), event_count, created_at
+ FROM ics_feeds WHERE enabled = 1 ORDER BY name`)
+}
+
+func (s *Store) queryICSFeeds(query string) ([]ICSFeed, error) {
+ rows, err := s.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var feeds []ICSFeed
+ for rows.Next() {
+ var f ICSFeed
+ var lastFetched sql.NullTime
+ if err := rows.Scan(&f.ID, &f.Name, &f.URL, &f.Enabled, &lastFetched,
+ &f.LastError, &f.EventCount, &f.CreatedAt); err != nil {
+ return nil, err
+ }
+ if lastFetched.Valid {
+ t := lastFetched.Time
+ f.LastFetchedAt = &t
+ }
+ feeds = append(feeds, f)
+ }
+ return feeds, rows.Err()
+}
+
+// SetICSFeedEnabled turns a feed on or off without losing it.
+func (s *Store) SetICSFeedEnabled(id int64, enabled bool) error {
+ _, err := s.db.Exec(`UPDATE ics_feeds SET enabled = ? WHERE id = ?`, enabled, id)
+ return err
+}
+
+// DeleteICSFeed removes a feed entirely.
+func (s *Store) DeleteICSFeed(id int64) error {
+ _, err := s.db.Exec(`DELETE FROM ics_feeds WHERE id = ?`, id)
+ return err
+}
+
+// RecordICSFeedResult stores the outcome of a fetch. A successful fetch clears
+// any previous error, so a feed that recovers stops being reported as broken.
+func (s *Store) RecordICSFeedResult(id int64, eventCount int, fetchErr error) error {
+ errText := ""
+ if fetchErr != nil {
+ errText = fetchErr.Error()
+ }
+ _, err := s.db.Exec(`
+ UPDATE ics_feeds
+ SET last_fetched_at = CURRENT_TIMESTAMP, last_error = ?, event_count = ?
+ WHERE id = ?
+ `, errText, eventCount, id)
+ return err
+}
diff --git a/internal/store/ics_feeds_test.go b/internal/store/ics_feeds_test.go
new file mode 100644
index 0000000..5b8889c
--- /dev/null
+++ b/internal/store/ics_feeds_test.go
@@ -0,0 +1,144 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func setupICSStore(t *testing.T) *Store {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "ics.db"))
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(`
+ CREATE TABLE ics_feeds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ url TEXT NOT NULL UNIQUE,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ last_fetched_at DATETIME,
+ last_error TEXT,
+ event_count INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );`); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ return &Store{db: db}
+}
+
+func TestICSFeeds_AddAndList(t *testing.T) {
+ s := setupICSStore(t)
+ defer func() { _ = s.Close() }()
+
+ if err := s.AddICSFeed("Seahawks", "https://example.com/sea.ics"); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ feeds, err := s.GetICSFeeds()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(feeds) != 1 || feeds[0].Name != "Seahawks" || !feeds[0].Enabled {
+ t.Fatalf("got %+v", feeds)
+ }
+ if feeds[0].LastFetchedAt != nil {
+ t.Error("a feed that has never been fetched should have no timestamp")
+ }
+}
+
+// Re-adding the same URL should update and re-enable rather than error, since
+// "add it again" is the obvious way to try to fix a disabled feed.
+func TestICSFeeds_ReAddingUpdatesAndReEnables(t *testing.T) {
+ s := setupICSStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddICSFeed("Old name", "https://example.com/sea.ics")
+ feeds, _ := s.GetICSFeeds()
+ _ = s.SetICSFeedEnabled(feeds[0].ID, false)
+
+ if err := s.AddICSFeed("Seahawks", "https://example.com/sea.ics"); err != nil {
+ t.Fatalf("re-add: %v", err)
+ }
+ feeds, _ = s.GetICSFeeds()
+ if len(feeds) != 1 {
+ t.Fatalf("expected 1 feed, got %d", len(feeds))
+ }
+ if feeds[0].Name != "Seahawks" || !feeds[0].Enabled {
+ t.Errorf("got %+v, want renamed and re-enabled", feeds[0])
+ }
+}
+
+func TestICSFeeds_DisabledAreExcludedFromFetchList(t *testing.T) {
+ s := setupICSStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddICSFeed("On", "https://example.com/on.ics")
+ _ = s.AddICSFeed("Off", "https://example.com/off.ics")
+ all, _ := s.GetICSFeeds()
+ for _, f := range all {
+ if f.Name == "Off" {
+ _ = s.SetICSFeedEnabled(f.ID, false)
+ }
+ }
+
+ enabled, err := s.GetEnabledICSFeeds()
+ if err != nil {
+ t.Fatalf("enabled: %v", err)
+ }
+ if len(enabled) != 1 || enabled[0].Name != "On" {
+ t.Errorf("got %+v, want only the enabled feed", enabled)
+ }
+}
+
+// A failure must be recorded, and a later success must clear it — otherwise a
+// feed that recovers still shows as broken forever.
+func TestICSFeeds_RecordResultClearsPreviousError(t *testing.T) {
+ s := setupICSStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddICSFeed("Seahawks", "https://example.com/sea.ics")
+ feeds, _ := s.GetICSFeeds()
+ id := feeds[0].ID
+
+ if err := s.RecordICSFeedResult(id, 0, errors.New("feed returned HTTP 404")); err != nil {
+ t.Fatalf("record failure: %v", err)
+ }
+ feeds, _ = s.GetICSFeeds()
+ if feeds[0].LastError == "" {
+ t.Fatal("expected the failure to be recorded")
+ }
+ if feeds[0].LastFetchedAt == nil {
+ t.Error("a failed fetch is still a fetch attempt and should be timestamped")
+ }
+
+ if err := s.RecordICSFeedResult(id, 21, nil); err != nil {
+ t.Fatalf("record success: %v", err)
+ }
+ feeds, _ = s.GetICSFeeds()
+ if feeds[0].LastError != "" {
+ t.Errorf("a successful fetch should clear the error, got %q", feeds[0].LastError)
+ }
+ if feeds[0].EventCount != 21 {
+ t.Errorf("EventCount = %d, want 21", feeds[0].EventCount)
+ }
+}
+
+func TestICSFeeds_Delete(t *testing.T) {
+ s := setupICSStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddICSFeed("Seahawks", "https://example.com/sea.ics")
+ feeds, _ := s.GetICSFeeds()
+ if err := s.DeleteICSFeed(feeds[0].ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ feeds, _ = s.GetICSFeeds()
+ if len(feeds) != 0 {
+ t.Errorf("got %d feeds after delete", len(feeds))
+ }
+}