summaryrefslogtreecommitdiff
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
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
-rw-r--r--cmd/dashboard/main.go4
-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
-rw-r--r--migrations/033_ics_feeds.sql28
-rw-r--r--migrations/README.md3
-rw-r--r--web/templates/partials/ics-feeds.html43
-rw-r--r--web/templates/settings.html26
13 files changed, 1092 insertions, 7 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index bfd890a..56251e0 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -383,6 +383,10 @@ func main() {
// Settings
r.Get("/settings", h.HandleSettingsPage)
+ r.Get("/settings/ics-feeds", h.HandleGetICSFeeds)
+ r.Post("/settings/ics-feeds", h.HandleAddICSFeed)
+ r.Post("/settings/ics-feeds/{id}/toggle", h.HandleToggleICSFeed)
+ r.Delete("/settings/ics-feeds/{id}", h.HandleDeleteICSFeed)
r.Post("/settings/sync", h.HandleSyncSources)
r.Post("/settings/clear-cache", h.HandleClearCache)
r.Post("/settings/toggle", h.HandleToggleSourceConfig)
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))
+ }
+}
diff --git a/migrations/033_ics_feeds.sql b/migrations/033_ics_feeds.sql
new file mode 100644
index 0000000..b7f2d75
--- /dev/null
+++ b/migrations/033_ics_feeds.sql
@@ -0,0 +1,28 @@
+-- Subscribed iCalendar (.ics) feeds.
+--
+-- doot reads Google Calendar with 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 being the case
+-- that prompted this — are distributed almost exclusively as .ics.
+--
+-- Feeds are fetched on the normal calendar refresh and their events are merged
+-- into calendar_events alongside Google ones, so they appear on the timeline
+-- without the timeline needing to know they came from a different place.
+--
+-- last_error records the most recent failure and is cleared on success, so a
+-- feed that has quietly stopped resolving is visible in settings rather than
+-- just showing no events.
+
+CREATE TABLE IF NOT EXISTS 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
+);
+
+CREATE INDEX IF NOT EXISTS idx_ics_feeds_enabled ON ics_feeds(enabled);
diff --git a/migrations/README.md b/migrations/README.md
index a98efa2..1564b48 100644
--- a/migrations/README.md
+++ b/migrations/README.md
@@ -28,10 +28,11 @@ Migration 007 was reserved during development but never implemented. The gap is
Migrations are applied automatically on application startup via `store.RunMigrations()`. The `schema_migrations` table tracks which migrations have been applied.
| 032 | `032_import_trello_shopping_items.sql` | One-time import of Trello Shopping-board cards (Hardware Store + unlisted) into user items, ahead of removing Trello as a shopping source |
+| 033 | `033_ics_feeds.sql` | Subscribed iCalendar (.ics) feeds, merged into calendar_events |
## Creating New Migrations
-1. Create file: `migrations/NNN_description.sql` (next number is 033)
+1. Create file: `migrations/NNN_description.sql` (next number is 034)
2. Write idempotent SQL (use `IF NOT EXISTS` where possible)
3. Test locally: `go run cmd/dashboard/main.go`
4. Verify: Check `schema_migrations` table for new entry
diff --git a/web/templates/partials/ics-feeds.html b/web/templates/partials/ics-feeds.html
new file mode 100644
index 0000000..4ee1ad6
--- /dev/null
+++ b/web/templates/partials/ics-feeds.html
@@ -0,0 +1,43 @@
+{{define "ics-feed-list"}}
+<div id="ics-feed-list" class="space-y-3">
+ {{if .Feeds}}
+ {{range .Feeds}}
+ <div class="flex flex-wrap items-center gap-3 p-3 rounded-lg border border-white/10 bg-white/5">
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2">
+ <span class="text-white {{if not .Enabled}}opacity-50{{end}}">{{.Name}}</span>
+ {{if not .Enabled}}<span class="text-xs text-white/40">(off)</span>{{end}}
+ </div>
+ <div class="text-xs text-white/40 truncate">{{.URL}}</div>
+ <div class="text-xs mt-1 {{if .LastError}}text-red-400{{else}}text-white/40{{end}}">
+ {{if .LastError}}
+ Failed: {{.LastError}}
+ {{else}}
+ {{.EventCount}} events · last checked {{.LastFetched}}
+ {{end}}
+ </div>
+ </div>
+ <form hx-post="/settings/ics-feeds/{{.ID}}/toggle" hx-target="#ics-feed-list" hx-swap="outerHTML">
+ <input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
+ <button type="submit" class="text-xs px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white/70 hover:text-white transition-colors">
+ {{if .Enabled}}Disable{{else}}Enable{{end}}
+ </button>
+ </form>
+ <button class="text-xs px-3 py-1.5 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
+ hx-delete="/settings/ics-feeds/{{.ID}}"
+ hx-target="#ics-feed-list"
+ hx-swap="outerHTML"
+ hx-confirm="Remove {{.Name}}? Its events disappear from the timeline.">
+ Remove
+ </button>
+ </div>
+ {{end}}
+ {{else}}
+ <p class="text-sm text-white/40">
+ No calendar feeds yet. Paste an .ics or webcal:// link below — published
+ schedules (sports fixtures, school terms, bin collections) are almost
+ always distributed this way.
+ </p>
+ {{end}}
+</div>
+{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index 8a72287..1156ef5 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -215,6 +215,32 @@
</div>
</div>
+ <!-- Calendar feeds (.ics) -->
+ <div class="mb-8 p-4 rounded-xl border border-white/10 bg-white/5">
+ <h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">Calendar Feeds (.ics)</h3>
+ <p class="text-xs text-white/40 mb-4">
+ Subscribed calendars fetched directly by doot. Google Calendar is read
+ through a service account, which cannot see calendars you subscribe to
+ in your own account &mdash; so published schedules go here instead.
+ </p>
+
+ {{template "ics-feed-list" .}}
+
+ <form class="flex flex-wrap gap-2 mt-4"
+ hx-post="/settings/ics-feeds"
+ hx-target="#ics-feed-list"
+ hx-swap="outerHTML"
+ hx-on::after-request="if(event.detail.successful) this.reset()">
+ <input type="text" name="name" placeholder="Name (e.g. Seahawks)" required
+ class="flex-1 min-w-32 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/40">
+ <input type="text" name="url" placeholder="https://... .ics or webcal://..." required
+ class="flex-[2] min-w-48 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/40">
+ <button type="submit" class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
+ Add Feed
+ </button>
+ </form>
+ </div>
+
{{template "sync-log" .SyncLog}}
<div id="sources-container" class="grid gap-8 mt-8">