summaryrefslogtreecommitdiff
path: root/web/templates
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 /web/templates
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 'web/templates')
-rw-r--r--web/templates/partials/ics-feeds.html43
-rw-r--r--web/templates/settings.html26
2 files changed, 69 insertions, 0 deletions
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">