oficina / calendar Public

app: the calendar itself — views, events, reminders, ICS

Five scriptless views. Week and day are packed on the SERVER: clusters
joined transitively by overlap, greedy lane assignment per cluster, and
percentages emitted as inline style. Packing overlaps is normally a job
for JavaScript measuring boxes in a browser; doing it here is what makes
the scriptless page the product rather than the fallback. Per cluster
and not per day, so two meetings at nine do not squeeze a lone four
o'clock to a third of the column.

Click-a-time-to-create with no script: 48 ordinary half-hour links per
day column, each with a screen-reader name saying its time, each an
ordinary GET to a prefilled form.

Month is a real <table>. A month IS tabular data, so the row and column
headers come free for a screen reader and a scriptless page keeps
semantics a div grid would rebuild with ARIA. Six rows always, so the
page does not jump height between months. No spanning bars: a bar must
be laid out against every other event in the week and breaks at every
week boundary, and each day saying what is on it costs nothing.

Today opens with a sentence, because the feeling a day produces is not
a list — it is whether there is room in it, and that is arithmetic the
server already has. Overlapping meetings are counted once. Notes are
excluded: a birthday does not make a Tuesday fuller. When the day is
full it says "There is no unbooked hour between them", which is true and
slightly alarming, which is the day the person is having.

Three kinds — meeting, commitment, note — with the radio descriptions
saying what actually differs, since three bare words are three words
nobody can choose between. They differ in reminder defaults, in whether
they count toward booked hours, and in how they draw.

Reminders survive hibernation, three ways. Options.NextDue tells the
activator the next moment there is work so the platform wakes the
process for it; the declared tick drains inside the request, because
202-and-a-goroutine is a reliable way to be hibernated mid-send; and any
page view opportunistically takes one. If the first two are
misconfigured the app still reminds people, just less punctually.

Delivery is at-least-once and every ambiguous case resolves towards
sending, because a reminder that never arrives is worse than one that
arrives twice. A claim lease bounds the duplicate window; lateness is
recorded and the message says "this reminder is N late" rather than
implying it was on time.

The tick route is mounted OUTSIDE csrf.Protect, with a test standing in
front of it. csrf.Protect refuses any POST carrying no Sec-Fetch-Site,
Origin or Referer, and the platform's scheduler sends none of the three;
inside the protected router a tick 403s forever and the platform reads
4xx as "do not retry". It fails silently and permanently, so the test
asserts the refusal comes from the tick guard and not from CSRF.

The one rule has its own test, and it greps the raw SQLite bytes and the
WAL rather than asking SQLite — a column that was never sealed looks
identical through Go and completely different on disk. It checkpoints
first, or it would pass by looking at a file the data has not reached.

go:embed needed all:. The default pattern skips names beginning with an
underscore, so templates/_chip.html silently did not exist and the app
panicked at init.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
Paul Campbell pushed by paul@keymail.dev f314a5095b7e12b3d13a550609b7305f952f0696 parent b6e2e40
48 files changed, +5231 −62
  • .impeccable/hook.cache.json +1 −0
  • cmd/calendar/main.go +6 −0
  • internal/calendar/app.go +14 −40
  • internal/calendar/funcs.go +140 −0
  • internal/calendar/handlers.go +5 −0
  • internal/calendar/handlers_events.go +383 −0
  • internal/calendar/handlers_jobs.go +141 −0
  • internal/calendar/handlers_misc.go +287 −0
  • internal/calendar/handlers_views.go +203 −0
  • internal/calendar/ics_export.go +208 −0
  • internal/calendar/ics_import.go +359 −0
  • internal/calendar/pack.go +156 −0
  • internal/calendar/render.go +17 −3
  • internal/calendar/repeat.go +248 −0
  • internal/calendar/routes.go +113 −0
  • internal/calendar/static/calendar.css +314 −0
  • internal/calendar/store_calendar.go +26 −0
  • internal/calendar/store_drain.go +195 −0
  • internal/calendar/store_event.go +10 −0
  • internal/calendar/store_feed.go +199 −0
  • internal/calendar/store_reminder.go +275 −0
  • internal/calendar/summary.go +185 −0
  • internal/calendar/templates/_chip.html +19 −0
  • internal/calendar/templates/agenda.html +36 −0
  • internal/calendar/templates/calendars.html +24 −0
  • internal/calendar/templates/day.html +63 −0
  • internal/calendar/templates/event.html +88 −0
  • internal/calendar/templates/event_delete.html +41 −0
  • internal/calendar/templates/event_form.html +83 −0
  • internal/calendar/templates/import.html +34 −0
  • internal/calendar/templates/import_done.html +41 −0
  • internal/calendar/templates/index.html +0 −11
  • internal/calendar/templates/layout.html +23 −3
  • internal/calendar/templates/month.html +51 −0
  • internal/calendar/templates/settings.html +53 −0
  • internal/calendar/templates/share.html +52 −0
  • internal/calendar/templates/week.html +60 −0
  • internal/calendar/testing.go +29 −0
  • internal/calendar/viewdata.go +244 −0
  • internal/calendar/viewmonth.go +249 −0
  • internal/calendar/zones.go +114 −0
  • internal/calendartest/auth_test.go +1 −1
  • internal/calendartest/events_test.go +237 −0
  • internal/calendartest/harness_test.go +18 −0
  • internal/calendartest/index_test.go +5 −3
  • internal/calendartest/migrate_test.go +1 −1
  • internal/calendartest/summary_test.go +115 −0
  • internal/calendartest/tick_test.go +65 −0
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json
new file mode 100644
index 0000000..966f739
--- /dev/null
+++ b/.impeccable/hook.cache.json
@@ -0,0 +1 @@
+{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788306865526,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}}
\ No newline at end of file
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go
index 4ab068b..78a4e27 100644
--- a/cmd/calendar/main.go
+++ b/cmd/calendar/main.go
@@ -74,6 +74,12 @@ func main() {
opts.Mux = app.Handler()
opts.DBPath = ""
+ // The scheduled-wake poll. The activator asks a running instance
+ // this before hibernating it and wakes it at the answer, which is
+ // what makes a reminder fire on a machine that is asleep almost all
+ // the time. Without it a 09:00 reminder arrives whenever somebody
+ // next happens to load a page.
+ opts.NextDue = app.NextDue
// The same page a handler's own error path renders, so a panic and
// a handled failure look identical to whoever hits one.
opts.ErrorPage = calendar.ErrorPage
diff --git a/internal/calendar/app.go b/internal/calendar/app.go
index 14ff8fd..58ccd29 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -5,11 +5,9 @@ import (
"fmt"
"log/slog"
"net/http"
-
- "github.com/go-chi/chi/v5"
+ "time"
rauth "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
@@ -126,46 +124,22 @@ func New(cfg Config) (*App, error) {
mailRecorder: recorder,
auth: authPlugin,
}
- h := &handlers{store: store, logger: logger, origin: origin}
-
- r := chi.NewRouter()
- // App-wide, from day one, so every state-changing route added later
- // is born covered. Origin-checking, not tokens — nothing to mint or
- // forget in a form.
- r.Use(csrf.Protect(origin))
-
- // Signed-out.
- r.Get("/signin", h.signin)
- r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin"))
- r.Get("/auth/callback", authPlugin.Callback)
- r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
-
- // The light/dark switcher. Outside the session group on purpose:
- // the sign-in page is a page too, and someone who needs a
- // high-contrast theme should not have to sign in to get one.
- r.Post("/mode", h.mode)
-
- // Signed-in. requirePerson runs after RequireSession and turns the
- // verified address into this app's own person row, so every handler
- // below can take an id rather than an address.
- r.Group(func(r chi.Router) {
- r.Use(authPlugin.RequireSession)
- r.Use(a.requirePerson)
- r.Get("/", h.index)
- })
-
- mux := http.NewServeMux()
- // The app serves its own static files — the framework never does.
- // They are embedded and fingerprinted: templates link them via
- // {{asset ...}}, which returns a content-hashed URL this handler
- // serves cacheable-forever.
- mux.Handle("GET /static/", assets.Handler())
- mux.Handle("/", r)
- a.mux = mux
+ h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer}
+ a.mux = a.routes(h, authPlugin, list, origin)
return a, nil
}
+// NextDue answers the platform's scheduled-wake poll: the next moment
+// this instance has work. rastrillo mounts it as GET /api/next-due,
+// outside app middleware, and the activator asks before hibernating.
+//
+// Without it, a reminder due at 09:00 on an instance nobody visits
+// would arrive whenever somebody next loaded a page. With it, the
+// platform wakes the process at 09:00 to send it.
+func (a *App) NextDue() time.Time {
+ return a.store.NextDueAt(context.Background())
+}
+
// Handler is the app's router, for rastrillo.Options.Mux and for
// tests. It is typed *http.ServeMux rather than http.Handler because
// Options.Mux is typed that way.
diff --git a/internal/calendar/funcs.go b/internal/calendar/funcs.go
new file mode 100644
index 0000000..fd15705
--- /dev/null
+++ b/internal/calendar/funcs.go
@@ -0,0 +1,140 @@
+package calendar
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+)
+
+// appFuncs are this app's own template helpers, on top of rastrillo's.
+//
+// Each one exists because the alternative is arithmetic or branching
+// inside a template, and a template is the one place in this app that
+// has no tests and no compiler. Anything with a decision in it is
+// computed in Go and printed here.
+var appFuncs = template.FuncMap{
+ "if_eq": ifEq,
+ "repeatOptions": repeatOptions,
+ "calendarOptions": calendarOptions,
+ "eventCancelHref": eventCancelHref,
+ "zoneOptions": zoneOptions,
+ "weekStartOptions": weekStartOptions,
+ "minutesLabel": minutesLabel,
+ "feedURL": feedURL,
+ "deleteAllLabel": deleteAllLabel,
+ "canWrite": CanWrite,
+}
+
+// deleteAllLabel names the expensive option with its own count, so the
+// warning is in the label rather than in a sentence beside it.
+//
+// "All 52 of them" is a different decision from "All". A series with no
+// end has no count to give, and says that instead of inventing one.
+func deleteAllLabel(count int, forever bool) string {
+ if forever || count <= 0 {
+ return "Every occurrence, including all the future ones"
+ }
+ if count == 1 {
+ return "The only occurrence"
+ }
+ return fmt.Sprintf("All %d of them", count)
+}
+
+// ifEq returns value when field matches want, and "" otherwise. It is
+// how a shared error string is shown against exactly the field it
+// belongs to, without the form data carrying one error slot per field.
+func ifEq(field, want, value string) string {
+ if field == want {
+ return value
+ }
+ return ""
+}
+
+// repeatOptions builds the repeat picker, labelled against this event's
+// own start date so it says "Every Wednesday" rather than "Weekly".
+func repeatOptions(startLocal, tz string, allDay bool, selected string) []map[string]any {
+ if selected == "" {
+ selected = RepeatNone
+ }
+ var out []map[string]any
+ for _, c := range RepeatChoicesFor(startLocal, tz, allDay) {
+ out = append(out, map[string]any{
+ "Value": c.Value,
+ "Label": c.Label,
+ "Selected": c.Value == selected,
+ })
+ }
+ return out
+}
+
+func calendarOptions(cals []CalendarView, selected int64) []map[string]any {
+ var out []map[string]any
+ for _, c := range cals {
+ out = append(out, map[string]any{
+ "Value": strconv.FormatInt(c.ID, 10),
+ "Label": c.Name,
+ "Selected": c.ID == selected,
+ })
+ }
+ return out
+}
+
+// eventCancelHref sends Cancel back where it came from: the event's own
+// page when editing, and the calendar when creating, because a new
+// event has no page to return to.
+func eventCancelHref(eventID int64) string {
+ if eventID == 0 {
+ return "/today"
+ }
+ return "/events/" + strconv.FormatInt(eventID, 10)
+}
+
+func zoneOptions(selected string) []map[string]any {
+ var out []map[string]any
+ found := false
+ for _, z := range CommonZones {
+ if z == selected {
+ found = true
+ }
+ out = append(out, map[string]any{"Value": z, "Label": z, "Selected": z == selected})
+ }
+ // Somebody in a zone the short list omits must not have it silently
+ // replaced by the first option the moment they save anything else.
+ if !found && selected != "" {
+ out = append(out, map[string]any{"Value": selected, "Label": selected, "Selected": true})
+ }
+ return out
+}
+
+func weekStartOptions(selected int) []map[string]any {
+ return []map[string]any{
+ {"Value": strconv.Itoa(WeekStartMonday), "Label": "Monday", "Selected": selected == WeekStartMonday},
+ {"Value": strconv.Itoa(WeekStartSaturday), "Label": "Saturday", "Selected": selected == WeekStartSaturday},
+ {"Value": strconv.Itoa(WeekStartSunday), "Label": "Sunday", "Selected": selected == WeekStartSunday},
+ }
+}
+
+// minutesLabel writes a reminder offset the way somebody would say it.
+func minutesLabel(min int) string {
+ switch {
+ case min == 0:
+ return "at the time it starts"
+ case min < 0:
+ return fmt.Sprintf("%s afterwards", humanDuration(-min))
+ case min%(24*60) == 0:
+ d := min / (24 * 60)
+ if d == 1 {
+ return "one day before"
+ }
+ return fmt.Sprintf("%d days before", d)
+ default:
+ return humanDuration(min) + " before"
+ }
+}
+
+// feedURL builds the absolute subscription URL. Absolute because a
+// calendar client is given this by copy and paste and has no page to
+// resolve a relative path against.
+func feedURL(origin, token string) string {
+ return origin + "/feed/" + token + ".ics"
+}
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go
index 56c5282..fa66a94 100644
--- a/internal/calendar/handlers.go
+++ b/internal/calendar/handlers.go
@@ -4,6 +4,8 @@ import (
"log/slog"
"net/http"
"strings"
+
+ "github.com/carlosframework/rastrillo/mail"
)
// handlers holds what every handler needs. One value, built in New.
@@ -15,6 +17,9 @@ type handlers struct {
// decide Secure cookie attributes and to build the absolute URLs a
// calendar subscription needs.
origin string
+
+ // mailer sends reminders. The same sender sign-in uses.
+ mailer mail.Sender
}
// secure reports whether cookies this app sets should carry Secure. It
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go
new file mode 100644
index 0000000..a988643
--- /dev/null
+++ b/internal/calendar/handlers_events.go
@@ -0,0 +1,383 @@
+package calendar
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// eventForm is the create/edit form's view data. It carries the
+// submitted values back on a validation failure, so a rejected form
+// never loses what somebody typed — the single most common way a form
+// wastes a person's time.
+type eventForm struct {
+ Action string
+ Title string
+ Heading string
+ Submit string
+ Calendars []CalendarView
+
+ EventID int64
+ CalendarID int64
+ EventTitle string
+ Location string
+ Notes string
+ AllDay bool
+ StartLocal string
+ EndLocal string
+ TZ string
+ Kind string
+ Status string
+
+ Repeat string
+ RepeatEnd string
+
+ Error string
+ ErrorField string
+
+ // Series is set when editing something that recurs, so the form can
+ // ask which occurrences the edit applies to.
+ Series bool
+}
+
+// newEvent renders the create form, prefilled from the query so that a
+// click on an empty slot arrives with its time already set.
+func (h *handlers) newEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ cals, err := h.store.Calendars(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "list calendars", err)
+ return
+ }
+ q := r.URL.Query()
+
+ f := eventForm{
+ Action: "/events",
+ Title: "New event",
+ Heading: "New event",
+ Submit: "Create",
+ Calendars: writable(cals),
+ TZ: p.Zone,
+ Kind: KindMeeting,
+ Status: StatusConfirmed,
+ Repeat: "none",
+ }
+ f.CalendarID, _ = strconv.ParseInt(q.Get("calendar"), 10, 64)
+ if f.CalendarID == 0 {
+ f.CalendarID, _ = h.store.DefaultCalendarID(r.Context(), p.ID)
+ }
+ f.StartLocal = q.Get("start")
+ f.EndLocal = q.Get("end")
+ if f.StartLocal == "" {
+ // No slot was clicked: offer the next half hour, which is what
+ // somebody creating an event from the toolbar usually means.
+ loc, err := Zone(p.Zone)
+ if err != nil {
+ loc = time.UTC
+ }
+ next := time.Now().In(loc).Truncate(30 * time.Minute).Add(30 * time.Minute)
+ f.StartLocal = next.Format(LayoutLocal)
+ f.EndLocal = next.Add(time.Hour).Format(LayoutLocal)
+ }
+ if d := q.Get("date"); d != "" {
+ f.AllDay = true
+ f.StartLocal = d
+ if t, err := ParseDate(d, p.Zone); err == nil {
+ f.EndLocal = t.AddDate(0, 0, 1).Format(LayoutDate)
+ }
+ }
+ render(w, "event_form", h.page(r, f.Title, f))
+}
+
+// createEvent handles POST /events.
+func (h *handlers) createEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ in, f, ok := h.readEventForm(r, p)
+ if !ok {
+ f.Action, f.Title, f.Heading, f.Submit = "/events", "New event", "New event", "Create"
+ cals, _ := h.store.Calendars(r.Context(), p.ID)
+ f.Calendars = writable(cals)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "event_form", h.page(r, f.Title, f))
+ return
+ }
+ e, err := h.store.CreateEvent(r.Context(), p.ID, in)
+ switch {
+ case NotFound(err):
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ case err != nil:
+ if ve, isVE := Invalid(err); isVE {
+ f.Error, f.ErrorField = ve.Message, ve.Field
+ cals, _ := h.store.Calendars(r.Context(), p.ID)
+ f.Action, f.Title, f.Heading, f.Submit = "/events", "New event", "New event", "Create"
+ f.Calendars = writable(cals)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "event_form", h.page(r, f.Title, f))
+ return
+ }
+ h.fail(w, r, "create event", err)
+ return
+ }
+ // A default reminder for the person who created it, using their own
+ // preference for the kind of thing it is.
+ if err := h.store.EnsureDefaultReminder(r.Context(), p, e); err != nil {
+ h.logger.Error("calendar: default reminder", "err", err)
+ }
+ http.Redirect(w, r, "/events/"+strconv.FormatInt(e.ID, 10), http.StatusSeeOther)
+}
+
+// showEvent renders one event, or one occurrence of a series.
+func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ e, err := h.store.Event(r.Context(), p.ID, id)
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "read event", err)
+ return
+ }
+
+ // An occurrence URL carries which one, so "edit this one" knows
+ // what it is editing.
+ occurrence := ""
+ if raw := chi.URLParam(r, "occ"); raw != "" {
+ local, ok := expandCompact(raw)
+ if !ok {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ occurrence = local
+ }
+
+ reminders, err := h.store.RemindersFor(r.Context(), p.ID, e.ID)
+ if err != nil {
+ h.fail(w, r, "read reminders", err)
+ return
+ }
+
+ data := map[string]any{
+ "Event": e,
+ "Chip": chipFor(e, p.Zone, time.Now().Unix()),
+ "Occurrence": occurrence,
+ "Reminders": reminders,
+ "Zone": p.Zone,
+ "RuleText": DescribeRule(e.RRule),
+ }
+ render(w, "event", h.page(r, e.Title, data))
+}
+
+// editEvent renders the edit form.
+func (h *handlers) editEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ e, err := h.store.Event(r.Context(), p.ID, id)
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "read event", err)
+ return
+ }
+ cals, err := h.store.Calendars(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "list calendars", err)
+ return
+ }
+ render(w, "event_form", h.page(r, "Edit", eventForm{
+ Action: "/events/" + strconv.FormatInt(e.ID, 10),
+ Title: "Edit",
+ Heading: "Edit event",
+ Submit: "Save",
+ Calendars: writable(cals),
+ EventID: e.ID,
+ CalendarID: e.CalendarID,
+ EventTitle: e.Title,
+ Location: e.Location,
+ Notes: e.Notes,
+ AllDay: e.AllDay,
+ StartLocal: e.StartLocal,
+ EndLocal: e.EndLocal,
+ TZ: e.TZ,
+ Kind: e.Kind,
+ Status: e.Status,
+ Repeat: RepeatFromRule(e.RRule),
+ Series: e.RRule != "",
+ }))
+}
+
+// updateEvent handles POST /events/{id}.
+func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ in, f, ok := h.readEventForm(r, p)
+ if !ok {
+ f.Action = "/events/" + strconv.FormatInt(id, 10)
+ f.Title, f.Heading, f.Submit = "Edit", "Edit event", "Save"
+ cals, _ := h.store.Calendars(r.Context(), p.ID)
+ f.Calendars = writable(cals)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "event_form", h.page(r, f.Title, f))
+ return
+ }
+ err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
+ switch {
+ case NotFound(err):
+ ErrorPage(w, r, http.StatusNotFound, "")
+ case err == ErrReadOnly:
+ ErrorPage(w, r, http.StatusForbidden, "")
+ case err != nil:
+ h.fail(w, r, "update event", err)
+ default:
+ http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+ }
+}
+
+// deleteEvent handles POST /events/{id}/delete.
+func (h *handlers) deleteEvent(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ _ = r.ParseForm()
+ scope := r.FormValue("scope")
+ occurrence := r.FormValue("occurrence")
+
+ // "Just this one" is the default, and it is first in the form. The
+ // destructive end of the scale — every occurrence, with its count
+ // named — is last, so the cheap answer is never one keystroke away
+ // from the expensive one.
+ if scope == "this" && occurrence != "" {
+ if err := h.store.ExcludeOccurrence(r.Context(), p.ID, id, occurrence); err != nil {
+ h.deleteFailed(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ if err := h.store.DeleteEvent(r.Context(), p.ID, id); err != nil {
+ h.deleteFailed(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (h *handlers) deleteFailed(w http.ResponseWriter, r *http.Request, err error) {
+ switch {
+ case NotFound(err):
+ ErrorPage(w, r, http.StatusNotFound, "")
+ case err == ErrReadOnly:
+ ErrorPage(w, r, http.StatusForbidden, "")
+ default:
+ h.fail(w, r, "delete event", err)
+ }
+}
+
+// confirmDelete is the destructive action's own URL, never a modal —
+// so it works with no script, can be linked to, and can be backed out
+// of with the browser's own back button.
+func (h *handlers) confirmDelete(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ e, err := h.store.Event(r.Context(), p.ID, id)
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "read event", err)
+ return
+ }
+ count, forever := h.store.CountOccurrences(e)
+ render(w, "event_delete", h.page(r, "Delete", map[string]any{
+ "Event": e,
+ "Occurrence": r.URL.Query().Get("occurrence"),
+ "Series": e.RRule != "",
+ "Count": count,
+ "Forever": forever,
+ }))
+}
+
+// readEventForm parses and validates a submission, returning the form
+// data to re-render on failure.
+func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventForm, bool) {
+ _ = r.ParseForm()
+ calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64)
+ allDay := r.FormValue("all_day") == "1"
+ start := strings.TrimSpace(r.FormValue("start"))
+
+ in := EventInput{
+ CalendarID: calID,
+ Title: r.FormValue("title"),
+ Location: r.FormValue("location"),
+ Notes: r.FormValue("notes"),
+ AllDay: allDay,
+ StartLocal: start,
+ EndLocal: strings.TrimSpace(r.FormValue("end")),
+ TZ: p.Zone,
+ Kind: r.FormValue("kind"),
+ Status: r.FormValue("status"),
+ RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, p.Zone, allDay),
+ }
+ f := eventForm{
+ CalendarID: calID,
+ EventTitle: in.Title,
+ Location: in.Location,
+ Notes: in.Notes,
+ AllDay: allDay,
+ StartLocal: in.StartLocal,
+ EndLocal: in.EndLocal,
+ TZ: p.Zone,
+ Kind: in.Kind,
+ Status: in.Status,
+ Repeat: r.FormValue("repeat"),
+ RepeatEnd: r.FormValue("repeat_end"),
+ }
+ if err := ValidateEventInput(&in); err != nil {
+ if ve, ok := Invalid(err); ok {
+ f.Error, f.ErrorField = ve.Message, ve.Field
+ } else {
+ f.Error = "That did not work."
+ }
+ return in, f, false
+ }
+ return in, f, true
+}
+
+// writable is the calendars somebody may actually create an event on.
+// A dropdown offering a calendar the server would refuse is a dropdown
+// that teaches people the app is broken.
+func writable(cals []CalendarView) []CalendarView {
+ out := make([]CalendarView, 0, len(cals))
+ for _, c := range cals {
+ if CanWrite(c.Role) {
+ out = append(out, c)
+ }
+ }
+ return out
+}
diff --git a/internal/calendar/handlers_jobs.go b/internal/calendar/handlers_jobs.go
new file mode 100644
index 0000000..995c57c
--- /dev/null
+++ b/internal/calendar/handlers_jobs.go
@@ -0,0 +1,141 @@
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/rastrillo/carlos"
+)
+
+// drainReminders is the platform's scheduled-work handler: POST
+// /jobs/reminders, mounted outside CSRF (see mountTick) and guarded by
+// carlos.Tick.
+//
+// The work happens INSIDE the request, synchronously. The platform
+// holds the instance awake for exactly as long as this handler is open
+// and starts the idle clock when it returns, so answering 202 and
+// continuing in a goroutine is a reliable way to be hibernated
+// mid-send. 2xx means done, 5xx means retry, 4xx means do not.
+func (h *handlers) drainReminders(w http.ResponseWriter, r *http.Request) {
+ if !carlos.Tick(r) {
+ // Constant-time bearer check, false when there is no token in
+ // the environment — so this fails closed off-platform rather
+ // than exposing a send-everything endpoint on the internet.
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ sent, err := h.drain(r.Context(), time.Now())
+ if err != nil {
+ // 5xx so the platform retries. The occurrence key makes a
+ // retry safe.
+ h.logger.Error("calendar: drain reminders", "err", err)
+ http.Error(w, "retry", http.StatusInternalServerError)
+ return
+ }
+ h.logger.Info("calendar: reminders drained", "sent", sent)
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// drainDeadline bounds one pass, so a very long queue cannot hold the
+// instance awake indefinitely. What is left is still due, and the next
+// wake takes it.
+const drainDeadline = 45 * time.Second
+
+// drain sends every reminder that has come due.
+//
+// Delivery is AT LEAST ONCE, deliberately. A reminder that never
+// arrives is worse than one that arrives twice, so every ambiguous case
+// resolves towards sending: a claim that expired, a process killed
+// between the send and the mark, a tick delivered twice. The
+// occurrence key is what keeps "twice" rare rather than routine.
+func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) {
+ deadline := now.Add(drainDeadline)
+ sent := 0
+ for time.Now().Before(deadline) {
+ due, ok, err := h.store.ClaimDueReminder(ctx, now.Unix())
+ if err != nil {
+ return sent, err
+ }
+ if !ok {
+ return sent, nil
+ }
+ if err := h.sendReminder(ctx, due, now); err != nil {
+ // Leave it claimed; the lease expires and a later pass
+ // retries. Recording the attempt is what stops a
+ // permanently undeliverable address being retried forever.
+ h.logger.Error("calendar: send reminder", "err", err, "attempts", due.Queue.Attempts)
+ if err := h.store.FailReminder(ctx, due.Queue.ID); err != nil {
+ return sent, err
+ }
+ continue
+ }
+ if err := h.store.MarkReminderSent(ctx, due.Queue.ID, now.Unix()); err != nil {
+ return sent, err
+ }
+ // Queue the rule's next occurrence. One row per rule, always.
+ if err := h.store.RescheduleReminder(ctx, due.Queue.ReminderID); err != nil {
+ return sent, err
+ }
+ sent++
+ }
+ return sent, nil
+}
+
+// sendReminder delivers one.
+//
+// The subject carries the title, which means the title leaves the
+// sealed database and travels through the mail relay into somebody's
+// mailbox. That is delivery rather than storage, and it is the product
+// working — but it is a real leak and the README says so rather than
+// letting the one rule be read as covering it.
+func (h *handlers) sendReminder(ctx context.Context, due DueReminder, now time.Time) error {
+ if h.mailer == nil {
+ return fmt.Errorf("calendar: no mailer configured")
+ }
+ when := hhmm(due.StartUTC, due.Zone)
+ subject := fmt.Sprintf("%s at %s", due.Title, when)
+
+ body := fmt.Sprintf("%s\n\n%s at %s", due.Title, due.DayWords, when)
+ if due.Location != "" {
+ body += "\n" + due.Location
+ }
+ body += "\n\n" + h.origin + fmt.Sprintf("/events/%d", due.EventID)
+
+ // Lateness is stated rather than hidden. An instance that was
+ // asleep fires late, and a reminder that arrives at 09:05 for a
+ // 09:00 meeting should say so instead of implying it was on time.
+ if late := now.Unix() - due.Queue.DueAt; late > 120 {
+ body += fmt.Sprintf("\n\nThis reminder is %s late.", humanDuration(int(late/60)))
+ }
+ return h.mailer.Send(ctx, due.Address, subject, body)
+}
+
+// DrainOnRequest is the opportunistic third entrance to the queue.
+//
+// Three ways a reminder gets sent, and the redundancy is the point:
+// the platform's own wake from NextDue, the declared schedule's tick,
+// and this — any signed-in page view noticing that something is
+// overdue. If the first two are misconfigured the app still works,
+// just less punctually, rather than silently never reminding anybody.
+//
+// It is bounded to a single reminder per request so that loading a page
+// never becomes slow because a queue got long.
+func (h *handlers) DrainOnRequest(ctx context.Context) {
+ due, ok, err := h.store.ClaimDueReminder(ctx, time.Now().Unix())
+ if err != nil || !ok {
+ return
+ }
+ now := time.Now()
+ if err := h.sendReminder(ctx, due, now); err != nil {
+ h.logger.Error("calendar: opportunistic reminder", "err", err)
+ _ = h.store.FailReminder(ctx, due.Queue.ID)
+ return
+ }
+ if err := h.store.MarkReminderSent(ctx, due.Queue.ID, now.Unix()); err != nil {
+ h.logger.Error("calendar: mark reminder sent", "err", err)
+ return
+ }
+ _ = h.store.RescheduleReminder(ctx, due.Queue.ReminderID)
+}
diff --git a/internal/calendar/handlers_misc.go b/internal/calendar/handlers_misc.go
new file mode 100644
index 0000000..a1e2292
--- /dev/null
+++ b/internal/calendar/handlers_misc.go
@@ -0,0 +1,287 @@
+package calendar
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// calendars lists what the viewer can see.
+func (h *handlers) calendars(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ cals, err := h.store.Calendars(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "list calendars", err)
+ return
+ }
+ render(w, "calendars", h.page(r, "Calendars", map[string]any{"Calendars": cals}))
+}
+
+// share shows a calendar's subscription feeds, and what they cost.
+func (h *handlers) share(w http.ResponseWriter, r *http.Request) {
+ p, cal, ok := h.calendarParam(w, r)
+ if !ok {
+ return
+ }
+ feeds, err := h.store.FeedsFor(r.Context(), p.ID, cal.ID)
+ if err != nil {
+ h.fail(w, r, "list feeds", err)
+ return
+ }
+ render(w, "share", h.page(r, "Share "+cal.Name, map[string]any{
+ "Calendar": cal,
+ "Feeds": feeds,
+ "Origin": h.origin,
+ "NewToken": r.URL.Query().Get("token"),
+ }))
+}
+
+// mintFeed creates a subscription URL and shows it once.
+func (h *handlers) mintFeed(w http.ResponseWriter, r *http.Request) {
+ p, cal, ok := h.calendarParam(w, r)
+ if !ok {
+ return
+ }
+ token, err := h.store.MintFeed(r.Context(), p.ID, cal.ID)
+ if err != nil {
+ h.fail(w, r, "mint feed", err)
+ return
+ }
+ // The token rides back in the query because this is the only moment
+ // it can ever be shown: only its hash is stored.
+ http.Redirect(w, r,
+ fmt.Sprintf("/calendars/%d/share?token=%s", cal.ID, token),
+ http.StatusSeeOther)
+}
+
+func (h *handlers) revokeFeed(w http.ResponseWriter, r *http.Request) {
+ p, cal, ok := h.calendarParam(w, r)
+ if !ok {
+ return
+ }
+ _ = r.ParseForm()
+ feedID, _ := strconv.ParseInt(r.FormValue("feed_id"), 10, 64)
+ if err := h.store.RevokeFeed(r.Context(), p.ID, feedID); err != nil && !NotFound(err) {
+ h.fail(w, r, "revoke feed", err)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/calendars/%d/share", cal.ID), http.StatusSeeOther)
+}
+
+// feed serves a subscription. No session: the token IS the
+// authorisation, because Google and Apple will not sign in.
+func (h *handlers) feed(w http.ResponseWriter, r *http.Request) {
+ token := chi.URLParam(r, "token")
+ f, err := h.store.FeedByToken(r.Context(), token)
+ if err != nil {
+ // A bad token is a 404, never a 403: a distinguishable refusal
+ // would let somebody test tokens for existence.
+ http.NotFound(w, r)
+ return
+ }
+ cal, err := h.store.Calendar(r.Context(), f.PersonID, f.CalendarID)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ from, to := feedWindow()
+ events, err := h.store.EventsForFeed(r.Context(), f.PersonID, f.CalendarID, from, to)
+ if err != nil {
+ h.logger.Error("calendar: feed events", "err", err)
+ http.Error(w, "unavailable", http.StatusInternalServerError)
+ return
+ }
+ body := WriteICS(cal.Name, events, r.URL.Query().Get("alarms") == "1")
+
+ // An ETag so a subscriber that polls every fifteen minutes usually
+ // gets a 304. On a platform billed by residency, a 304 is the
+ // difference between a feed that costs a little and one that costs
+ // a lot.
+ etag := etagFor(body)
+ w.Header().Set("ETag", etag)
+ w.Header().Set("Cache-Control", "private, max-age=300")
+ if match := r.Header.Get("If-None-Match"); match == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
+ _, _ = w.Write([]byte(body))
+}
+
+// feedWindow is how much of a calendar a subscription carries: a year
+// back and two forward. Unbounded would mean re-sending a decade of
+// history every poll.
+func feedWindow() (int64, int64) {
+ now := time.Now()
+ return now.AddDate(-1, 0, 0).Unix(), now.AddDate(2, 0, 0).Unix()
+}
+
+// exportCalendar downloads one calendar as a file.
+func (h *handlers) exportCalendar(w http.ResponseWriter, r *http.Request) {
+ p, cal, ok := h.calendarParam(w, r)
+ if !ok {
+ return
+ }
+ from, to := feedWindow()
+ events, err := h.store.EventsForFeed(r.Context(), p.ID, cal.ID, from, to)
+ if err != nil {
+ h.fail(w, r, "export calendar", err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
+ w.Header().Set("Content-Disposition",
+ fmt.Sprintf("attachment; filename=%q", safeFilename(cal.Name)+".ics"))
+ _, _ = w.Write([]byte(WriteICS(cal.Name, events, true)))
+}
+
+func (h *handlers) importForm(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ cals, err := h.store.Calendars(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "list calendars", err)
+ return
+ }
+ render(w, "import", h.page(r, "Import", map[string]any{
+ "Calendars": writable(cals),
+ }))
+}
+
+// importICS reads an uploaded file synchronously.
+//
+// Synchronous, not a background job: a job needs a status page, a
+// polling shim and a story for what happens when the instance
+// hibernates mid-import. A file small enough to upload is small enough
+// to parse in the request, and the deferred work has a written trigger
+// — an import that exceeds the request timeout.
+func (h *handlers) importICS(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ if err := r.ParseMultipartForm(MaxImportBytes); err != nil {
+ h.importFailed(w, r, "That file could not be read.")
+ return
+ }
+ calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64)
+ if _, err := h.store.Calendar(r.Context(), p.ID, calID); err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ h.importFailed(w, r, "Choose a .ics file to import.")
+ return
+ }
+ defer file.Close()
+
+ inputs, res, err := ParseICS(file, p.Zone)
+ if err != nil {
+ h.importFailed(w, r, "That file could not be read.")
+ return
+ }
+ for _, in := range inputs {
+ in.CalendarID = calID
+ if _, err := h.store.CreateEvent(r.Context(), p.ID, in); err != nil {
+ // One unusable event does not fail the file. It is counted
+ // and the page says how many.
+ res.Skipped++
+ continue
+ }
+ res.Created++
+ }
+ render(w, "import_done", h.page(r, "Imported", map[string]any{"Result": res}))
+}
+
+func (h *handlers) importFailed(w http.ResponseWriter, r *http.Request, msg string) {
+ p, _ := CurrentPerson(r)
+ cals, _ := h.store.Calendars(r.Context(), p.ID)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "import", h.page(r, "Import", map[string]any{
+ "Calendars": writable(cals),
+ "Error": msg,
+ }))
+}
+
+func (h *handlers) settings(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ undeliverable, err := h.store.UndeliverableReminders(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "count undeliverable", err)
+ return
+ }
+ render(w, "settings", h.page(r, "Settings", map[string]any{
+ "Person": p,
+ "Zones": CommonZones,
+ "Undeliverable": undeliverable,
+ "Saved": r.URL.Query().Get("saved") == "1",
+ }))
+}
+
+func (h *handlers) saveSettings(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ _ = r.ParseForm()
+ weekStart, _ := strconv.Atoi(r.FormValue("week_start"))
+ reminderMin, _ := strconv.Atoi(r.FormValue("default_reminder_min"))
+ allDayMin, _ := strconv.Atoi(r.FormValue("default_all_day_min"))
+
+ err := h.store.SaveSettings(r.Context(), p.ID, r.FormValue("zone"), weekStart, reminderMin, allDayMin)
+ if err != nil {
+ if ve, ok := Invalid(err); ok {
+ undeliverable, _ := h.store.UndeliverableReminders(r.Context(), p.ID)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "settings", h.page(r, "Settings", map[string]any{
+ "Person": p, "Zones": CommonZones,
+ "Undeliverable": undeliverable, "Error": ve.Message,
+ }))
+ return
+ }
+ h.fail(w, r, "save settings", err)
+ return
+ }
+ http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
+}
+
+func (h *handlers) addReminder(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ _ = r.ParseForm()
+ offset, _ := strconv.ParseInt(r.FormValue("offset_min"), 10, 64)
+ if err := h.store.AddReminder(r.Context(), p.ID, id, offset); err != nil && !NotFound(err) {
+ h.fail(w, r, "add reminder", err)
+ return
+ }
+ http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+}
+
+func (h *handlers) removeReminder(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ _ = r.ParseForm()
+ remID, _ := strconv.ParseInt(r.FormValue("reminder_id"), 10, 64)
+ if err := h.store.RemoveReminder(r.Context(), p.ID, remID); err != nil && !NotFound(err) {
+ h.fail(w, r, "remove reminder", err)
+ return
+ }
+ http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+}
+
+// calendarParam resolves {id}, answering 404 for anything the viewer
+// may not see.
+func (h *handlers) calendarParam(w http.ResponseWriter, r *http.Request) (Person, CalendarView, bool) {
+ p, _ := CurrentPerson(r)
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return p, CalendarView{}, false
+ }
+ cal, err := h.store.Calendar(r.Context(), p.ID, id)
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return p, CalendarView{}, false
+ }
+ if err != nil {
+ h.fail(w, r, "read calendar", err)
+ return p, CalendarView{}, false
+ }
+ return p, cal, true
+}
diff --git a/internal/calendar/handlers_views.go b/internal/calendar/handlers_views.go
new file mode 100644
index 0000000..39076bc
--- /dev/null
+++ b/internal/calendar/handlers_views.go
@@ -0,0 +1,203 @@
+package calendar
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// today redirects to the day page for the viewer's own today.
+//
+// A redirect rather than its own rendering, so "today" and "2 September"
+// are one page with one set of behaviours. The alternative — a Today
+// page that is subtly different from the Day page — is how an
+// affordance ends up existing in one and not the other.
+func (h *handlers) today(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ loc, err := Zone(p.Zone)
+ if err != nil {
+ loc = time.UTC
+ }
+ http.Redirect(w, r, "/day/"+time.Now().In(loc).Format(LayoutDate), http.StatusSeeOther)
+}
+
+// day renders one day: the sentence, the all-day strip, the packed
+// column, and a half-hour link in every empty slot.
+func (h *handlers) day(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ day, err := ParseDate(chi.URLParam(r, "date"), p.Zone)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ from, to, err := DayBounds(day, p.Zone)
+ if err != nil {
+ h.fail(w, r, "day bounds", err)
+ return
+ }
+ events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
+ if err != nil {
+ h.fail(w, r, "day range", err)
+ return
+ }
+ defaultCal, err := h.store.DefaultCalendarID(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "default calendar", err)
+ return
+ }
+ page, err := BuildDay(day, events, p.Zone, time.Now().Unix(), defaultCal)
+ if err != nil {
+ h.fail(w, r, "build day", err)
+ return
+ }
+
+ loc, _ := Zone(p.Zone)
+ data := map[string]any{
+ "Day": page,
+ "PrevISO": day.In(loc).AddDate(0, 0, -1).Format(LayoutDate),
+ "NextISO": day.In(loc).AddDate(0, 0, 1).Format(LayoutDate),
+ "TodayISO": time.Now().In(loc).Format(LayoutDate),
+ "Zone": p.Zone,
+ }
+ render(w, "day", h.page(r, page.Title, data))
+}
+
+// week renders seven packed columns. The date in the URL is any day in
+// the week, so "the week of the 2nd" and "the week of the 4th" are the
+// same page rather than two.
+func (h *handlers) week(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ anyDay, err := ParseDate(chi.URLParam(r, "date"), p.Zone)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ start, err := WeekStartFor(anyDay, p.WeekStart, p.Zone)
+ if err != nil {
+ h.fail(w, r, "week start", err)
+ return
+ }
+ from := start.Unix()
+ to := start.AddDate(0, 0, 7).Unix()
+
+ events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
+ if err != nil {
+ h.fail(w, r, "week range", err)
+ return
+ }
+ defaultCal, err := h.store.DefaultCalendarID(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "default calendar", err)
+ return
+ }
+
+ now := time.Now().Unix()
+ var days []DayPage
+ for i := 0; i < 7; i++ {
+ d := start.AddDate(0, 0, i)
+ ds, de, err := DayBounds(d, p.Zone)
+ if err != nil {
+ h.fail(w, r, "week day bounds", err)
+ return
+ }
+ // Each column gets only the events overlapping its own day, so
+ // the packer never lays out a Tuesday meeting against
+ // Wednesday's.
+ var inDay []EventView
+ for _, e := range events {
+ if e.OccEndUTC > ds && e.OccStartUTC < de {
+ inDay = append(inDay, e)
+ }
+ }
+ page, err := BuildDay(d, inDay, p.Zone, now, defaultCal)
+ if err != nil {
+ h.fail(w, r, "build week day", err)
+ return
+ }
+ days = append(days, page)
+ }
+
+ loc, _ := Zone(p.Zone)
+ title := fmt.Sprintf("Week of %s", start.Format("2 January 2006"))
+ data := map[string]any{
+ "Days": days,
+ "StartISO": start.Format(LayoutDate),
+ "PrevISO": start.AddDate(0, 0, -7).Format(LayoutDate),
+ "NextISO": start.AddDate(0, 0, 7).Format(LayoutDate),
+ "TodayISO": time.Now().In(loc).Format(LayoutDate),
+ "Zone": p.Zone,
+ }
+ render(w, "week", h.page(r, title, data))
+}
+
+// month renders the grid.
+func (h *handlers) month(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ month, err := ParseMonth(chi.URLParam(r, "month"), p.Zone)
+ if err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ from, to, err := MonthBounds(month, p.WeekStart, p.Zone)
+ if err != nil {
+ h.fail(w, r, "month bounds", err)
+ return
+ }
+ events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
+ if err != nil {
+ h.fail(w, r, "month range", err)
+ return
+ }
+ page, err := BuildMonth(month, p.WeekStart, events, p.Zone, time.Now().Unix())
+ if err != nil {
+ h.fail(w, r, "build month", err)
+ return
+ }
+ render(w, "month", h.page(r, page.Title, map[string]any{"Month": page}))
+}
+
+// agenda lists the next fortnight.
+func (h *handlers) agenda(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ loc, err := Zone(p.Zone)
+ if err != nil {
+ loc = time.UTC
+ }
+ from := time.Now().In(loc)
+ if s := r.URL.Query().Get("from"); s != "" {
+ if d, err := ParseDate(s, p.Zone); err == nil {
+ from = d
+ }
+ }
+ start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
+ events, err := h.store.EventsInRange(r.Context(), p.ID,
+ start.Unix(), start.AddDate(0, 0, AgendaDays).Unix())
+ if err != nil {
+ h.fail(w, r, "agenda range", err)
+ return
+ }
+ page, err := BuildAgenda(start, events, p.Zone, time.Now().Unix())
+ if err != nil {
+ h.fail(w, r, "build agenda", err)
+ return
+ }
+ data := map[string]any{
+ "Agenda": page,
+ "NextISO": start.AddDate(0, 0, AgendaDays).Format(LayoutDate),
+ "PrevISO": start.AddDate(0, 0, -AgendaDays).Format(LayoutDate),
+ }
+ render(w, "agenda", h.page(r, "Agenda", data))
+}
+
+// fail logs an internal error and renders the shared error page.
+//
+// The log line carries a stage name and the error, never a title, an
+// address or a date somebody chose: `carlos logs` is platform-held
+// storage we cannot grep afterwards, so nothing that identifies a
+// person or their content goes into it.
+func (h *handlers) fail(w http.ResponseWriter, r *http.Request, stage string, err error) {
+ h.logger.Error("calendar: "+stage, "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+}
diff --git a/internal/calendar/ics_export.go b/internal/calendar/ics_export.go
new file mode 100644
index 0000000..2556cb4
--- /dev/null
+++ b/internal/calendar/ics_export.go
@@ -0,0 +1,208 @@
+package calendar
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// ProdID identifies this app in the files it writes. The RFC requires
+// one, and importers use it to work around known generators — so it
+// names the app honestly rather than imitating anything.
+const ProdID = "-//oficina//calendar//EN"
+
+// WriteICS renders a calendar's events as an iCalendar stream.
+//
+// VTIMEZONE is NOT emitted, and this is a stated shortfall rather than
+// an oversight. A conforming VTIMEZONE carries the full transition
+// history for every zone used, which is a large amount of generated
+// text and a real source of subtle error. Instead each DTSTART carries
+// its IANA TZID, which Google, Apple and Thunderbird all resolve from
+// their own zone databases.
+//
+// What breaks: Outlook desktop, which historically resolves only the
+// zones it finds in the file. The README says so, and the trigger for
+// building it is an Outlook import showing a wrong time.
+func WriteICS(name string, events []EventView, withAlarms bool) string {
+ var b strings.Builder
+ line(&b, "BEGIN:VCALENDAR")
+ line(&b, "VERSION:2.0")
+ line(&b, "PRODID:"+ProdID)
+ line(&b, "CALSCALE:GREGORIAN")
+ line(&b, "METHOD:PUBLISH")
+ line(&b, "X-WR-CALNAME:"+escapeText(name))
+
+ for _, e := range events {
+ writeEvent(&b, e, withAlarms)
+ }
+ line(&b, "END:VCALENDAR")
+ return b.String()
+}
+
+func writeEvent(b *strings.Builder, e EventView, withAlarms bool) {
+ line(b, "BEGIN:VEVENT")
+
+ uid := e.UIDSealedOpen
+ if uid == "" {
+ // A VEVENT with no UID is one an importer will duplicate on
+ // every re-import. Derive a stable one rather than omit it.
+ uid = fmt.Sprintf("%d@calendar.oficina", e.ID)
+ }
+ line(b, "UID:"+escapeText(uid))
+ line(b, "DTSTAMP:"+utcStamp(e.UpdatedAt))
+ line(b, fmt.Sprintf("SEQUENCE:%d", e.Sequence))
+
+ if e.AllDay {
+ // A DATE value, never a DATETIME at midnight: the difference is
+ // what makes a birthday land on the same day everywhere rather
+ // than shifting west of Greenwich.
+ line(b, "DTSTART;VALUE=DATE:"+compactDate(e.StartLocal))
+ line(b, "DTEND;VALUE=DATE:"+compactDate(e.EndLocal))
+ } else if e.TZ == "" {
+ // Floating: no zone, no Z. Means "this wall time, wherever the
+ // reader is". Only ever produced from an import that was
+ // floating to begin with.
+ line(b, "DTSTART:"+compactLocal(e.StartLocal))
+ line(b, "DTEND:"+compactLocal(e.EndLocal))
+ } else {
+ line(b, "DTSTART;TZID="+e.TZ+":"+compactLocal(e.StartLocal))
+ line(b, "DTEND;TZID="+e.TZ+":"+compactLocal(e.EndLocal))
+ }
+
+ line(b, "SUMMARY:"+escapeText(e.Title))
+ if e.Location != "" {
+ line(b, "LOCATION:"+escapeText(e.Location))
+ }
+ if e.Notes != "" {
+ line(b, "DESCRIPTION:"+escapeText(e.Notes))
+ }
+ if e.RRule != "" {
+ line(b, "RRULE:"+e.RRule)
+ }
+ if e.Exdates != "" {
+ writeExdates(b, e)
+ }
+ if e.SeriesID != nil && e.RecurrenceIDLocal != "" {
+ if e.AllDay {
+ line(b, "RECURRENCE-ID;VALUE=DATE:"+compactDate(e.RecurrenceIDLocal))
+ } else if e.TZ != "" {
+ line(b, "RECURRENCE-ID;TZID="+e.TZ+":"+compactLocal(e.RecurrenceIDLocal))
+ } else {
+ line(b, "RECURRENCE-ID:"+compactLocal(e.RecurrenceIDLocal))
+ }
+ }
+ line(b, "STATUS:"+strings.ToUpper(e.Status))
+
+ // The app's own kind has no RFC equivalent, so it rides as an
+ // X- property: a client that does not know it ignores it, and a
+ // re-import into this app gets it back.
+ line(b, "X-OFICINA-KIND:"+e.Kind)
+
+ if withAlarms {
+ writeAlarm(b, e)
+ }
+ line(b, "END:VEVENT")
+}
+
+// writeExdates emits the excluded occurrences, grouped so the property
+// appears once rather than per date.
+func writeExdates(b *strings.Builder, e EventView) {
+ var dates []string
+ for _, d := range strings.Split(e.Exdates, ",") {
+ if d = strings.TrimSpace(d); d == "" {
+ continue
+ }
+ if e.AllDay {
+ dates = append(dates, compactDate(d))
+ } else {
+ dates = append(dates, compactLocal(d))
+ }
+ }
+ if len(dates) == 0 {
+ return
+ }
+ switch {
+ case e.AllDay:
+ line(b, "EXDATE;VALUE=DATE:"+strings.Join(dates, ","))
+ case e.TZ != "":
+ line(b, "EXDATE;TZID="+e.TZ+":"+strings.Join(dates, ","))
+ default:
+ line(b, "EXDATE:"+strings.Join(dates, ","))
+ }
+}
+
+// writeAlarm emits a display alarm, for subscribers that want the feed
+// to carry reminders. Off by default: a subscribed calendar that starts
+// making somebody's phone buzz without being asked is a bad surprise.
+func writeAlarm(b *strings.Builder, e EventView) {
+ if e.ReminderOffsetMin == 0 {
+ return
+ }
+ line(b, "BEGIN:VALARM")
+ line(b, "ACTION:DISPLAY")
+ line(b, "DESCRIPTION:"+escapeText(e.Title))
+ line(b, fmt.Sprintf("TRIGGER:-PT%dM", e.ReminderOffsetMin))
+ line(b, "END:VALARM")
+}
+
+// line writes one content line, folded.
+//
+// The RFC caps a line at 75 OCTETS, not characters, and continues it
+// with CRLF plus one space. Folding on characters would split a
+// multi-byte rune across the fold and produce a file that is not valid
+// UTF-8 — which importers reject in ways that look like a corrupt
+// calendar rather than a bad fold.
+func line(b *strings.Builder, s string) {
+ const limit = 75
+ if len(s) <= limit {
+ b.WriteString(s)
+ b.WriteString("\r\n")
+ return
+ }
+ written := 0
+ for i := 0; i < len(s); {
+ // Never split inside a rune: advance to a rune boundary.
+ end := i + limit
+ if written > 0 {
+ end = i + limit - 1 // the leading space costs one octet
+ }
+ if end > len(s) {
+ end = len(s)
+ }
+ for end > i && end < len(s) && !utf8Boundary(s[end]) {
+ end--
+ }
+ if written > 0 {
+ b.WriteString(" ")
+ }
+ b.WriteString(s[i:end])
+ b.WriteString("\r\n")
+ i = end
+ written++
+ }
+}
+
+// utf8Boundary reports whether b starts a rune (i.e. is not a
+// continuation byte).
+func utf8Boundary(b byte) bool { return b&0xC0 != 0x80 }
+
+// escapeText applies the RFC's TEXT escaping. The order matters:
+// backslash first, or the escapes this adds get escaped again.
+func escapeText(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ s = strings.ReplaceAll(s, ";", `\;`)
+ s = strings.ReplaceAll(s, ",", `\,`)
+ s = strings.ReplaceAll(s, "\r\n", `\n`)
+ s = strings.ReplaceAll(s, "\n", `\n`)
+ s = strings.ReplaceAll(s, "\r", `\n`)
+ return s
+}
+
+func utcStamp(t time.Time) string {
+ return t.UTC().Format("20060102T150405Z")
+}
+
+// compactDate turns "2026-09-02" into "20260902".
+func compactDate(local string) string {
+ return strings.ReplaceAll(local, "-", "")
+}
diff --git a/internal/calendar/ics_import.go b/internal/calendar/ics_import.go
new file mode 100644
index 0000000..cbad614
--- /dev/null
+++ b/internal/calendar/ics_import.go
@@ -0,0 +1,359 @@
+package calendar
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+)
+
+// MaxImportBytes bounds an upload. Ten megabytes is far more than a
+// decade of one person's calendar and far less than enough to exhaust
+// the instance.
+const MaxImportBytes = 10 << 20
+
+// ImportResult is what the import page reports. Every number it carries
+// is one the person can act on, and the dropped ones are named rather
+// than summed into a single "some events could not be imported".
+type ImportResult struct {
+ Created int
+ Updated int
+ Skipped int
+
+ // Floating counts events that arrived with no zone. They are kept,
+ // because refusing them loses data, but they are counted because a
+ // floating event on a shared calendar means different instants for
+ // different readers.
+ Floating int
+
+ // ForeignRules counts recurrences stored verbatim but not expanded.
+ ForeignRules int
+
+ // Attendees counts ATTENDEE lines dropped, because this app does
+ // not model guests yet. Reported rather than silently discarded:
+ // somebody importing a calendar full of invitations should know
+ // that the invitations did not come with it.
+ Attendees int
+
+ // UnknownZones names TZIDs that could not be resolved. Named, not
+ // counted, because the fix is per-zone.
+ UnknownZones []string
+}
+
+// ParseICS reads a stream into event inputs.
+//
+// Nothing here rejects a file. An importer's job is to get as much of
+// somebody's calendar in as it honestly can and to say plainly what it
+// could not — a parser that refuses a whole file over one malformed
+// line is a parser that loses a decade of someone's history to a typo
+// from a client they no longer use.
+func ParseICS(r io.Reader, defaultZone string) ([]EventInput, ImportResult, error) {
+ var (
+ out []EventInput
+ res ImportResult
+ cur *icsEvent
+ zones = map[string]bool{}
+ inVEV bool
+ inOther int
+ )
+
+ sc := bufio.NewScanner(io.LimitReader(r, MaxImportBytes))
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
+
+ var pending string
+ flush := func() {
+ if pending == "" {
+ return
+ }
+ name, params, value := splitLine(pending)
+ pending = ""
+
+ switch {
+ case name == "BEGIN" && value == "VEVENT":
+ inVEV, cur = true, &icsEvent{}
+ case name == "BEGIN":
+ // VTIMEZONE, VALARM, VTODO: skipped wholesale. Their
+ // properties must not land on the event being built.
+ if inVEV {
+ inOther++
+ }
+ case name == "END" && value == "VEVENT":
+ if cur != nil {
+ in, ev := cur.build(defaultZone, zones, &res)
+ if ev {
+ out = append(out, in)
+ }
+ }
+ inVEV, cur = false, nil
+ case name == "END":
+ if inOther > 0 {
+ inOther--
+ }
+ case inVEV && inOther == 0 && cur != nil:
+ cur.set(name, params, value, &res)
+ }
+ }
+
+ for sc.Scan() {
+ raw := sc.Text()
+ // A line beginning with a space or tab continues the previous
+ // one. Unfolding must happen before anything is parsed, or a
+ // long SUMMARY arrives truncated at 75 octets.
+ if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
+ pending += strings.TrimRight(raw[1:], "\r")
+ continue
+ }
+ flush()
+ pending = strings.TrimRight(raw, "\r")
+ }
+ flush()
+ if err := sc.Err(); err != nil {
+ return nil, res, fmt.Errorf("calendar: read ics: %w", err)
+ }
+ for z := range zones {
+ res.UnknownZones = append(res.UnknownZones, z)
+ }
+ return out, res, nil
+}
+
+// icsEvent accumulates one VEVENT's properties.
+type icsEvent struct {
+ uid string
+ summary string
+ location string
+ notes string
+ rrule string
+ exdates []string
+ status string
+ kind string
+
+ start, end string
+ startTZ string
+ allDay bool
+ hasStart bool
+ hasEnd bool
+ durationMins int
+}
+
+func (e *icsEvent) set(name, params, value string, res *ImportResult) {
+ switch name {
+ case "UID":
+ e.uid = unescapeText(value)
+ case "SUMMARY":
+ e.summary = unescapeText(value)
+ case "LOCATION":
+ e.location = unescapeText(value)
+ case "DESCRIPTION":
+ e.notes = unescapeText(value)
+ case "RRULE":
+ e.rrule = value
+ case "STATUS":
+ e.status = strings.ToLower(value)
+ case "X-OFICINA-KIND":
+ e.kind = strings.ToLower(value)
+ case "ATTENDEE":
+ res.Attendees++
+ case "EXDATE":
+ for _, d := range strings.Split(value, ",") {
+ if local, _, ok := parseICSTime(strings.TrimSpace(d), params); ok {
+ e.exdates = append(e.exdates, local)
+ }
+ }
+ case "DTSTART":
+ if local, allDay, ok := parseICSTime(value, params); ok {
+ e.start, e.allDay, e.hasStart = local, allDay, true
+ e.startTZ = tzidOf(params)
+ }
+ case "DTEND":
+ if local, _, ok := parseICSTime(value, params); ok {
+ e.end, e.hasEnd = local, true
+ }
+ case "DURATION":
+ e.durationMins = parseDurationMinutes(value)
+ }
+}
+
+// build turns the accumulated properties into an input.
+func (e *icsEvent) build(defaultZone string, zones map[string]bool, res *ImportResult) (EventInput, bool) {
+ if !e.hasStart || e.summary == "" {
+ res.Skipped++
+ return EventInput{}, false
+ }
+
+ zone := e.startTZ
+ switch {
+ case e.allDay:
+ zone = ""
+ case zone == "":
+ // Floating. Kept as floating rather than assumed into the
+ // importer's zone: guessing is how an event silently moves.
+ res.Floating++
+ default:
+ if _, err := Zone(zone); err != nil {
+ // An unresolvable TZID — a Windows zone name, usually.
+ // Fall back to the importing person's own zone and say so,
+ // rather than dropping the event.
+ zones[zone] = true
+ zone = defaultZone
+ }
+ }
+
+ end := e.end
+ if !e.hasEnd {
+ end = e.impliedEnd(zone)
+ }
+
+ if e.rrule != "" {
+ if _, err := ParseRule(e.rrule); err != nil {
+ res.ForeignRules++
+ }
+ }
+
+ kind := e.kind
+ switch kind {
+ case KindMeeting, KindCommitment, KindNote:
+ default:
+ // An imported event with no opinion is a meeting if it is
+ // timed and a note if it is all day — which is what a birthday
+ // or a public holiday actually is.
+ if e.allDay {
+ kind = KindNote
+ } else {
+ kind = KindMeeting
+ }
+ }
+
+ status := e.status
+ switch status {
+ case StatusConfirmed, StatusTentative, StatusCancelled:
+ default:
+ status = StatusConfirmed
+ }
+
+ return EventInput{
+ Title: e.summary,
+ Location: e.location,
+ Notes: e.notes,
+ AllDay: e.allDay,
+ StartLocal: e.start,
+ EndLocal: end,
+ TZ: zone,
+ RRule: e.rrule,
+ Exdates: strings.Join(e.exdates, ","),
+ Kind: kind,
+ Status: status,
+ }, true
+}
+
+// impliedEnd applies the RFC's defaults for a VEVENT with no DTEND: a
+// DURATION if one was given, otherwise one day for a DATE start and
+// zero length for a DATETIME one.
+func (e *icsEvent) impliedEnd(zone string) string {
+ loc, err := Zone(zone)
+ if err != nil {
+ loc = time.UTC
+ }
+ t, _, err := ParseLocal(e.start, loc)
+ if err != nil {
+ return e.start
+ }
+ switch {
+ case e.durationMins > 0:
+ return FormatLocal(t.Add(time.Duration(e.durationMins)*time.Minute), e.allDay)
+ case e.allDay:
+ return FormatLocal(t.AddDate(0, 0, 1), true)
+ default:
+ return e.start
+ }
+}
+
+// splitLine breaks "DTSTART;TZID=Europe/Dublin:20260902T090000" into
+// its name, parameters and value.
+func splitLine(s string) (name, params, value string) {
+ colon := strings.Index(s, ":")
+ if colon < 0 {
+ return strings.ToUpper(strings.TrimSpace(s)), "", ""
+ }
+ head, value := s[:colon], s[colon+1:]
+ if semi := strings.Index(head, ";"); semi >= 0 {
+ return strings.ToUpper(head[:semi]), head[semi+1:], value
+ }
+ return strings.ToUpper(head), "", value
+}
+
+func tzidOf(params string) string {
+ for _, p := range strings.Split(params, ";") {
+ if k, v, ok := strings.Cut(p, "="); ok && strings.EqualFold(k, "TZID") {
+ return strings.Trim(strings.TrimSpace(v), `"`)
+ }
+ }
+ return ""
+}
+
+// parseICSTime reads the three forms a DTSTART can take, and reports
+// whether it was a date.
+func parseICSTime(v, params string) (local string, allDay bool, ok bool) {
+ v = strings.TrimSpace(v)
+ if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(v) == 8 {
+ if t, err := time.Parse("20060102", v); err == nil {
+ return t.Format(LayoutDate), true, true
+ }
+ }
+ // A trailing Z is UTC. The wall time is kept as written and the
+ // zone recorded separately, so the record stays "what was said".
+ if strings.HasSuffix(v, "Z") {
+ if t, err := time.Parse("20060102T150405Z", v); err == nil {
+ return t.Format(LayoutLocal), false, true
+ }
+ }
+ if t, err := time.Parse("20060102T150405", v); err == nil {
+ return t.Format(LayoutLocal), false, true
+ }
+ return "", false, false
+}
+
+// parseDurationMinutes reads the subset of ISO 8601 durations a
+// calendar actually emits.
+func parseDurationMinutes(v string) int {
+ v = strings.TrimSpace(strings.ToUpper(v))
+ v = strings.TrimPrefix(v, "P")
+ var total, n int
+ timePart := false
+ for _, c := range v {
+ switch {
+ case c >= '0' && c <= '9':
+ n = n*10 + int(c-'0')
+ case c == 'T':
+ timePart = true
+ n = 0
+ case c == 'W':
+ total += n * 7 * 24 * 60
+ n = 0
+ case c == 'D':
+ total += n * 24 * 60
+ n = 0
+ case c == 'H':
+ total += n * 60
+ n = 0
+ case c == 'M' && timePart:
+ total += n
+ n = 0
+ default:
+ n = 0
+ }
+ }
+ return total
+}
+
+// unescapeText reverses the RFC's TEXT escaping. Backslash LAST, the
+// mirror of escapeText's first — otherwise "\\n" (an escaped
+// backslash followed by an n) becomes a newline.
+func unescapeText(s string) string {
+ s = strings.ReplaceAll(s, `\n`, "\n")
+ s = strings.ReplaceAll(s, `\N`, "\n")
+ s = strings.ReplaceAll(s, `\,`, ",")
+ s = strings.ReplaceAll(s, `\;`, ";")
+ s = strings.ReplaceAll(s, `\\`, `\`)
+ return s
+}
diff --git a/internal/calendar/pack.go b/internal/calendar/pack.go
new file mode 100644
index 0000000..8aaf9f3
--- /dev/null
+++ b/internal/calendar/pack.go
@@ -0,0 +1,156 @@
+package calendar
+
+// Packing overlapping events into columns is normally a job for
+// JavaScript measuring boxes in the browser. It is done here, on the
+// server, for the reason every affordance in this app is: the scriptless
+// page is the product, not the fallback.
+//
+// The arithmetic is percentages, emitted as inline style on each chip.
+// rastrillo's Content-Security-Policy allows inline styles — its own
+// partials carry them — so this needs no exception and no stylesheet
+// generated per request.
+
+// Placed is one event positioned inside a day column.
+type Placed struct {
+ Event EventView
+
+ // TopPct and HeightPct locate the chip in the day, 0-100.
+ TopPct float64
+ HeightPct float64
+
+ // LeftPct and WidthPct split the column between events that
+ // overlap. A lone event is 0 and 100.
+ LeftPct float64
+ WidthPct float64
+}
+
+// PackDay lays out one day's timed events between dayStart and dayEnd
+// (UTC seconds, half-open).
+//
+// Events are grouped into CLUSTERS — runs joined transitively by
+// overlap — and lanes are assigned per cluster rather than per day. The
+// difference matters: two meetings at 09:00 and a third at 16:00 should
+// leave the third full width, not a third of it, and a day-wide lane
+// count would squeeze every chip to the width of the worst hour.
+//
+// Within a cluster a chip takes the first lane whose previous event has
+// already ended. That is the greedy interval-graph colouring, and for
+// this shape it uses the minimum number of lanes.
+//
+// All-day events are not packed: they belong in the day's header strip,
+// not in the timed grid, and giving them a height would make a birthday
+// look like a 24-hour meeting.
+func PackDay(events []EventView, dayStart, dayEnd int64) []Placed {
+ span := float64(dayEnd - dayStart)
+ if span <= 0 {
+ return nil
+ }
+
+ timed := make([]EventView, 0, len(events))
+ for _, e := range events {
+ if e.AllDay {
+ continue
+ }
+ timed = append(timed, e)
+ }
+ if len(timed) == 0 {
+ return nil
+ }
+ sortOccurrences(timed)
+
+ out := make([]Placed, 0, len(timed))
+ for _, cluster := range clusters(timed) {
+ // laneEnd[i] is when the last event in lane i finishes.
+ var laneEnd []int64
+ lane := make([]int, len(cluster))
+ for i, e := range cluster {
+ start := clamp(e.OccStartUTC, dayStart, dayEnd)
+ placedInLane := -1
+ for l, end := range laneEnd {
+ if end <= start {
+ laneEnd[l] = clamp(e.OccEndUTC, dayStart, dayEnd)
+ placedInLane = l
+ break
+ }
+ }
+ if placedInLane < 0 {
+ laneEnd = append(laneEnd, clamp(e.OccEndUTC, dayStart, dayEnd))
+ placedInLane = len(laneEnd) - 1
+ }
+ lane[i] = placedInLane
+ }
+
+ lanes := float64(len(laneEnd))
+ for i, e := range cluster {
+ start := clamp(e.OccStartUTC, dayStart, dayEnd)
+ end := clamp(e.OccEndUTC, dayStart, dayEnd)
+ height := float64(end-start) / span * 100
+
+ // A zero-length event is a real thing somebody typed, and a
+ // chip with no height cannot be clicked or read. Give it
+ // the smallest height that still shows its title.
+ if height < minChipPct {
+ height = minChipPct
+ }
+ top := float64(start-dayStart) / span * 100
+ if top+height > 100 {
+ top = 100 - height
+ }
+
+ out = append(out, Placed{
+ Event: e,
+ TopPct: round2(top),
+ HeightPct: round2(height),
+ LeftPct: round2(float64(lane[i]) / lanes * 100),
+ WidthPct: round2(100 / lanes),
+ })
+ }
+ }
+ return out
+}
+
+// minChipPct is about twenty minutes of a day — enough vertical room
+// for one line of text at the grid's height.
+const minChipPct = 1.4
+
+// clusters groups events joined transitively by overlap. Input must be
+// sorted by start.
+func clusters(sorted []EventView) [][]EventView {
+ var out [][]EventView
+ var cur []EventView
+ var curEnd int64
+
+ for _, e := range sorted {
+ if len(cur) == 0 || e.OccStartUTC < curEnd {
+ cur = append(cur, e)
+ if e.OccEndUTC > curEnd {
+ curEnd = e.OccEndUTC
+ }
+ continue
+ }
+ out = append(out, cur)
+ cur = []EventView{e}
+ curEnd = e.OccEndUTC
+ }
+ if len(cur) > 0 {
+ out = append(out, cur)
+ }
+ return out
+}
+
+func clamp(v, lo, hi int64) int64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+// round2 keeps the emitted style attribute short. Sub-percent precision
+// is below one pixel on any real column width, and full float precision
+// would put seventeen digits in the HTML.
+func round2(f float64) float64 {
+ return float64(int(f*100+0.5)) / 100
+}
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index 59c311b..d31d622 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -12,7 +12,13 @@ import (
"amadan.net/oficina/calendar/internal/calendar/icons"
)
-//go:embed templates static
+// all:, not a bare pattern. The default embed pattern SKIPS files whose
+// names begin with "_" or "." — so templates/_chip.html, the partial
+// every view shares, silently did not exist in the embedded filesystem
+// and the app panicked at init with "pattern matches no files". The
+// prefix is what makes the underscore convention safe here.
+//
+//go:embed all:templates all:static
var appFS embed.FS
// assets fingerprints static/: {{asset "static/tokens.css"}} in a
@@ -24,7 +30,11 @@ var assets = rastrillo.NewAssets(appFS)
// discovered from the filesystem so a template that is never rendered
// is a compile-time-visible mistake rather than dead weight nobody
// notices.
-var pageNames = []string{"index", "signin", "errors"}
+var pageNames = []string{
+ "signin", "errors", "day", "week", "month", "agenda",
+ "event", "event_form", "event_delete", "calendars", "share",
+ "import", "import_done", "settings",
+}
// pages is one *template.Template per page, each combining layout.html
// with that page's own file — separate trees so every page can define a
@@ -42,8 +52,12 @@ func init() {
template.Must(template.New("layout").
Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
Funcs(template.FuncMap{"asset": assets.Path}).
+ Funcs(appFuncs).
ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+ // The shell, this app's own shared partials, and the
+ // page's body. _chip.html is shared by every view, so
+ // it is parsed into every tree rather than duplicated.
+ ParseFS(appFS, "templates/layout.html", "templates/_chip.html", "templates/"+name+".html"))
}
}
diff --git a/internal/calendar/repeat.go b/internal/calendar/repeat.go
new file mode 100644
index 0000000..04cfca5
--- /dev/null
+++ b/internal/calendar/repeat.go
@@ -0,0 +1,248 @@
+package calendar
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// The repeat vocabulary the form offers.
+//
+// Six choices, not an RRULE builder. A repeat picker that exposes
+// FREQ, INTERVAL, BYDAY, BYSETPOS and COUNT as separate controls is a
+// picker that makes somebody learn a specification to book a standup.
+// These six cover what people actually schedule, and each maps to one
+// unambiguous rule anchored on the event's own start date.
+//
+// Anything more expressive arrives by import, is stored verbatim, and
+// is shown on the event's page as the rule it is.
+const (
+ RepeatNone = "none"
+ RepeatDaily = "daily"
+ RepeatWeekdays = "weekdays"
+ RepeatWeekly = "weekly"
+ RepeatFortnight = "fortnightly"
+ RepeatMonthly = "monthly"
+ RepeatYearly = "yearly"
+)
+
+// RepeatChoice is one option in the picker.
+type RepeatChoice struct {
+ Value string
+ Label string
+}
+
+// RepeatChoicesFor labels the options against the event's own start
+// date, so the picker says "Every Wednesday" rather than "Weekly" and
+// nobody has to work out what the rule will mean.
+func RepeatChoicesFor(startLocal, zone string, allDay bool) []RepeatChoice {
+ out := []RepeatChoice{
+ {RepeatNone, "Does not repeat"},
+ {RepeatDaily, "Every day"},
+ {RepeatWeekdays, "Every weekday (Monday to Friday)"},
+ }
+ loc, err := Zone(zone)
+ if err != nil {
+ loc = time.UTC
+ }
+ t, _, err := ParseLocal(startLocal, loc)
+ if err != nil {
+ return append(out,
+ RepeatChoice{RepeatWeekly, "Every week"},
+ RepeatChoice{RepeatFortnight, "Every two weeks"},
+ RepeatChoice{RepeatMonthly, "Every month"},
+ RepeatChoice{RepeatYearly, "Every year"},
+ )
+ }
+ return append(out,
+ RepeatChoice{RepeatWeekly, "Every " + t.Format("Monday")},
+ RepeatChoice{RepeatFortnight, "Every second " + t.Format("Monday")},
+ RepeatChoice{RepeatMonthly, "Every month on the " + ordinal(t.Day())},
+ RepeatChoice{RepeatYearly, "Every year on " + t.Format("2 January")},
+ )
+}
+
+// RuleFromRepeat turns a picker choice into an RRULE anchored on the
+// event's start.
+//
+// repeatEnd is an optional "until" date from the form. It is emitted as
+// a bare local date rather than a UTC instant, because that is what it
+// is — somebody said "until the 31st", not "until 23:59:59Z on the
+// 31st", and converting it would move the last day for anyone whose
+// zone is far enough from UTC.
+func RuleFromRepeat(repeat, repeatEnd, startLocal, zone string, allDay bool) string {
+ var rule string
+ switch repeat {
+ case "", RepeatNone:
+ return ""
+ case RepeatDaily:
+ rule = "FREQ=DAILY"
+ case RepeatWeekdays:
+ rule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
+ case RepeatWeekly:
+ rule = "FREQ=WEEKLY"
+ case RepeatFortnight:
+ rule = "FREQ=WEEKLY;INTERVAL=2"
+ case RepeatMonthly:
+ rule = "FREQ=MONTHLY"
+ case RepeatYearly:
+ rule = "FREQ=YEARLY"
+ default:
+ return ""
+ }
+ if end := strings.TrimSpace(repeatEnd); end != "" {
+ if t, err := time.Parse(LayoutDate, end); err == nil {
+ rule += ";UNTIL=" + t.Format("20060102")
+ }
+ }
+ return rule
+}
+
+// RepeatFromRule is the inverse, so the edit form can reopen showing
+// what was chosen. A rule outside the six reads back as "none" and the
+// form says so rather than silently offering to replace it.
+func RepeatFromRule(rrule string) string {
+ if strings.TrimSpace(rrule) == "" {
+ return RepeatNone
+ }
+ r, err := ParseRule(rrule)
+ if err != nil {
+ return RepeatNone
+ }
+ switch r.Freq {
+ case FreqDaily:
+ if r.Interval == 1 {
+ return RepeatDaily
+ }
+ case FreqWeekly:
+ weekdaysOnly := len(r.ByDay) == 5 &&
+ containsWeekday(r.ByDay, time.Monday) && containsWeekday(r.ByDay, time.Friday) &&
+ !containsWeekday(r.ByDay, time.Saturday) && !containsWeekday(r.ByDay, time.Sunday)
+ switch {
+ case weekdaysOnly && r.Interval == 1:
+ return RepeatWeekdays
+ case len(r.ByDay) == 0 && r.Interval == 1:
+ return RepeatWeekly
+ case len(r.ByDay) == 0 && r.Interval == 2:
+ return RepeatFortnight
+ }
+ case FreqMonthly:
+ if r.Interval == 1 && r.MonthDay == 0 && r.Ordinal == 0 {
+ return RepeatMonthly
+ }
+ case FreqYearly:
+ if r.Interval == 1 {
+ return RepeatYearly
+ }
+ }
+ return RepeatNone
+}
+
+// DescribeRule writes a rule as a sentence for the event's page.
+//
+// A rule this app cannot express is described as itself — the raw
+// RRULE, labelled — rather than approximated. An approximation of a
+// recurrence is a lie about when something happens.
+func DescribeRule(rrule string) string {
+ if strings.TrimSpace(rrule) == "" {
+ return ""
+ }
+ r, err := ParseRule(rrule)
+ if err != nil {
+ return "Repeats on an imported rule this app does not edit: " + rrule
+ }
+
+ var base string
+ switch r.Freq {
+ case FreqDaily:
+ base = everyN(r.Interval, "day")
+ case FreqWeekly:
+ if len(r.ByDay) > 0 {
+ var names []string
+ for _, d := range r.ByDay {
+ names = append(names, d.String())
+ }
+ base = everyN(r.Interval, "week") + " on " + joinWords(names)
+ } else {
+ base = everyN(r.Interval, "week")
+ }
+ case FreqMonthly:
+ switch {
+ case r.Ordinal != 0:
+ base = fmt.Sprintf("%s on the %s %s", everyN(r.Interval, "month"),
+ ordinalWord(r.Ordinal), r.OrdinalDay.String())
+ case r.MonthDay > 0:
+ base = fmt.Sprintf("%s on the %s", everyN(r.Interval, "month"), ordinal(r.MonthDay))
+ default:
+ base = everyN(r.Interval, "month")
+ }
+ case FreqYearly:
+ base = everyN(r.Interval, "year")
+ }
+
+ switch {
+ case r.Count > 0:
+ return fmt.Sprintf("%s, %d times.", base, r.Count)
+ case r.HasUntil:
+ return fmt.Sprintf("%s, until %s.", base, r.Until.Format("2 January 2006"))
+ default:
+ return base + "."
+ }
+}
+
+func everyN(n int, unit string) string {
+ switch n {
+ case 1:
+ return "Every " + unit
+ case 2:
+ return "Every second " + unit
+ default:
+ return fmt.Sprintf("Every %d %ss", n, unit)
+ }
+}
+
+func ordinal(n int) string {
+ suffix := "th"
+ // 11th, 12th and 13th are the exceptions the naive rule gets wrong.
+ if n%100 < 11 || n%100 > 13 {
+ switch n % 10 {
+ case 1:
+ suffix = "st"
+ case 2:
+ suffix = "nd"
+ case 3:
+ suffix = "rd"
+ }
+ }
+ return fmt.Sprintf("%d%s", n, suffix)
+}
+
+func ordinalWord(n int) string {
+ switch n {
+ case 1:
+ return "first"
+ case 2:
+ return "second"
+ case 3:
+ return "third"
+ case 4:
+ return "fourth"
+ case -1:
+ return "last"
+ default:
+ return ordinal(n)
+ }
+}
+
+func joinWords(w []string) string {
+ switch len(w) {
+ case 0:
+ return ""
+ case 1:
+ return w[0]
+ case 2:
+ return w[0] + " and " + w[1]
+ default:
+ return strings.Join(w[:len(w)-1], ", ") + " and " + w[len(w)-1]
+ }
+}
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
new file mode 100644
index 0000000..b1fc260
--- /dev/null
+++ b/internal/calendar/routes.go
@@ -0,0 +1,113 @@
+package calendar
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/csrf"
+)
+
+// routes builds the whole router.
+//
+// Three tiers, and which tier a route sits in is a security decision
+// rather than a filing one:
+//
+// - Outside CSRF entirely: the scheduled tick. See mountTick.
+// - Inside CSRF, outside the session: sign-in, the theme switcher,
+// and the subscription feed, which is a capability URL rather than
+// a signed-in page.
+// - Inside the session: everything else.
+func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin string) *http.ServeMux {
+ r := chi.NewRouter()
+ // App-wide, so every state-changing route added later is born
+ // covered. Origin-checking, not tokens — nothing to mint or forget
+ // in a form.
+ r.Use(csrf.Protect(origin))
+
+ // Signed-out.
+ r.Get("/signin", h.signin)
+ r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin"))
+ r.Get("/auth/callback", authPlugin.Callback)
+ r.Get("/auth/verify", authPlugin.Verify)
+ r.Post("/signout", authPlugin.Signout)
+
+ // The light/dark switcher, outside the session on purpose: the
+ // sign-in page is a page too, and somebody who needs a
+ // high-contrast theme should not have to sign in to get one.
+ r.Post("/mode", h.mode)
+
+ // A subscription feed is a capability: the token in the URL is the
+ // whole authorisation, because Google and Apple will not sign in.
+ // Deliberately outside the session group — a signed-in check here
+ // would make the feed work in a browser and nowhere else.
+ r.Get("/feed/{token}.ics", h.feed)
+
+ r.Group(func(r chi.Router) {
+ r.Use(authPlugin.RequireSession)
+ r.Use(a.requirePerson)
+
+ r.Get("/", h.today)
+ r.Get("/today", h.today)
+ r.Get("/day/{date}", h.day)
+ r.Get("/week/{date}", h.week)
+ r.Get("/month/{month}", h.month)
+ r.Get("/agenda", h.agenda)
+
+ r.Get("/events/new", h.newEvent)
+ r.Post("/events", h.createEvent)
+ r.Get("/events/{id}", h.showEvent)
+ r.Get("/events/{id}/edit", h.editEvent)
+ r.Post("/events/{id}", h.updateEvent)
+ // The destructive action gets its own URL and its own page,
+ // never a modal: it works with no script, it can be linked to,
+ // and the browser's back button is a working cancel.
+ r.Get("/events/{id}/delete", h.confirmDelete)
+ r.Post("/events/{id}/delete", h.deleteEvent)
+ r.Post("/events/{id}/reminders", h.addReminder)
+ r.Post("/events/{id}/reminders/remove", h.removeReminder)
+ // One occurrence of a series. Last, so it cannot shadow the
+ // literal segments above.
+ r.Get("/events/{id}/{occ}", h.showEvent)
+
+ r.Get("/calendars", h.calendars)
+ r.Get("/calendars/{id}.ics", h.exportCalendar)
+ r.Get("/calendars/{id}/share", h.share)
+ r.Post("/calendars/{id}/feeds", h.mintFeed)
+ r.Post("/calendars/{id}/feeds/revoke", h.revokeFeed)
+
+ r.Get("/import", h.importForm)
+ r.Post("/import", h.importICS)
+
+ r.Get("/settings", h.settings)
+ r.Post("/settings", h.saveSettings)
+ })
+
+ mux := http.NewServeMux()
+ // The app serves its own static files — the framework never does.
+ mux.Handle("GET /static/", assets.Handler())
+ a.mountTick(mux, h)
+ mux.Handle("/", r)
+ return mux
+}
+
+// mountTick puts the platform's scheduled-work route on the bare mux,
+// OUTSIDE csrf.Protect.
+//
+// This is not tidiness, it is the difference between reminders working
+// and reminders never firing. csrf.Protect refuses any POST that
+// carries no Sec-Fetch-Site, Origin or Referer, and the platform's
+// scheduler sends none of the three — it is not a browser. Mounted
+// inside the protected router the tick would 403 forever, and the
+// platform reads 4xx as "do not retry", so it would fail silently and
+// permanently.
+//
+// carlos.Tick is the guard instead: a constant-time comparison against
+// this instance's own $CARLOS_ADMIN_TOKEN, false when there is no token
+// in the environment, so it fails closed off-platform rather than open.
+//
+// TestTheTickRouteIsNotBehindCSRF stands in front of this.
+func (a *App) mountTick(mux *http.ServeMux, h *handlers) {
+ mux.Handle("POST /jobs/reminders", http.HandlerFunc(h.drainReminders))
+}
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css
new file mode 100644
index 0000000..69fd075
--- /dev/null
+++ b/internal/calendar/static/calendar.css
@@ -0,0 +1,314 @@
+/* calendar.css — this app's own components.
+ *
+ * Prefix: cal-. Never rst- (rastrillo's namespace, and a future release
+ * may claim a name we took) and never estilo- (the suite's, and these
+ * are not suite components until a second app copies one).
+ *
+ * Grammar: the attribute spelling, matching rastrillo v3 — the KIND is
+ * a bare attribute, a VARIANT is a token in its value matched with ~=,
+ * and a PART is its own flat attribute. Adopted per screen, never per
+ * element.
+ *
+ * Every colour here is a --rst-* token or derived from one with
+ * color-mix. Nothing is a literal hex: the theme's own contrast table
+ * is measured, and a hex dropped in beside it is a value nobody has
+ * measured against anything.
+ */
+
+/* ── The view switcher ─────────────────────────────────────────── */
+
+[cal-bar] {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-4);
+}
+
+[cal-bar-spacer] { flex: 1 1 auto; }
+
+/* The day's sentence. It is the page's opening line, so it reads at
+ * body size rather than as a caption — a summary set in small grey
+ * type is a summary people learn to skip. */
+[cal-sentence] {
+ color: var(--rst-text);
+ margin: 0 0 var(--rst-sp-4);
+ max-width: 52rem;
+}
+
+/* ── Month ─────────────────────────────────────────────────────── */
+
+/* A real table: a month IS tabular data, so the row and column headers
+ * come free for a screen reader and a scriptless page keeps working
+ * semantics a div grid would have to rebuild with ARIA. */
+[cal-month] {
+ border-collapse: collapse;
+ table-layout: fixed;
+ width: 100%;
+}
+
+[cal-month] th {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ font-weight: 600;
+ padding: var(--rst-sp-2);
+ text-align: start;
+}
+
+[cal-month] td {
+ border: 1px solid var(--rst-line);
+ height: 7.5rem;
+ padding: 0;
+ vertical-align: top;
+}
+
+[cal-day-cell] {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ height: 100%;
+ padding: var(--rst-sp-1);
+}
+
+/* Outside the month: dimmed, but never hidden. A month grid whose
+ * leading and trailing days are blank makes the first week look
+ * broken. */
+[cal-day-cell~="outside"] { opacity: 0.55; }
+
+[cal-day-cell~="weekend"] { background: var(--rst-surface-2); }
+
+[cal-day-num] {
+ color: var(--rst-text-muted);
+ display: block;
+ font-size: var(--rst-fs-sm);
+ padding: 2px;
+ text-decoration: none;
+}
+
+[cal-day-num]:hover { text-decoration: underline; }
+
+/* Today. Three signals, never colour alone: a filled marker, bold
+ * weight, and the visually-hidden word "today" in the link's
+ * accessible name. */
+[cal-day-num~="today"] {
+ background: var(--rst-accent);
+ border-radius: var(--rst-radius-pill);
+ color: var(--rst-on-accent);
+ font-weight: 700;
+ inline-size: 1.6rem;
+ text-align: center;
+}
+
+[cal-more] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ padding-inline-start: 2px;
+}
+
+/* ── Chips ─────────────────────────────────────────────────────── */
+
+/* One event, wherever it appears. The hue is set inline per calendar
+ * as --cal-hue, and every colour below is mixed from it against a
+ * theme token — so a chip stays legible in both schemes without the
+ * calendar's colour being a measured value of its own. */
+[cal-chip] {
+ align-items: baseline;
+ background: color-mix(in oklab, hsl(var(--cal-hue, 220) 60% 50%) 14%, var(--rst-surface));
+ border-inline-start: 3px solid hsl(var(--cal-hue, 220) 55% 45%);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ display: flex;
+ font-size: var(--rst-fs-sm);
+ gap: var(--rst-sp-1);
+ overflow: hidden;
+ padding: 2px var(--rst-sp-1);
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+[cal-chip]:hover { text-decoration: underline; }
+
+[cal-chip-time] {
+ color: var(--rst-text-muted);
+ flex: none;
+ font-variant-numeric: tabular-nums;
+}
+
+[cal-chip-title] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* The past is dimmed AND written in the past tense — the tense is in
+ * the markup, so the meaning survives greyscale and a stylesheet that
+ * failed to load. */
+[cal-chip~="past"] { opacity: 0.6; }
+
+/* A cancelled event is struck through and says "Cancelled" in its own
+ * text. Never the line alone. */
+[cal-chip~="cancelled"] [cal-chip-title] { text-decoration: line-through; }
+
+/* A note is a thing that is true of a day without occupying it, so it
+ * is drawn as an outline rather than a filled block. */
+[cal-chip~="note"] {
+ background: transparent;
+ border: 1px dashed color-mix(in oklab, hsl(var(--cal-hue, 220) 55% 45%) 60%, var(--rst-line));
+ border-inline-start-width: 3px;
+}
+
+/* A commitment is yours rather than shared: a quieter fill than a
+ * meeting, so a day of your own work does not read as a day of
+ * meetings. */
+[cal-chip~="commitment"] {
+ background: color-mix(in oklab, hsl(var(--cal-hue, 220) 60% 50%) 7%, var(--rst-surface));
+}
+
+[cal-chip~="tentative"] {
+ border-inline-start-style: dotted;
+}
+
+/* ── Day and week columns ──────────────────────────────────────── */
+
+[cal-grid] {
+ display: grid;
+ gap: 0;
+ grid-template-columns: 3.5rem 1fr;
+}
+
+[cal-week-grid] {
+ display: grid;
+ gap: 0;
+ grid-template-columns: 3.5rem repeat(7, minmax(6rem, 1fr));
+}
+
+[cal-hours] {
+ border-inline-end: 1px solid var(--rst-line);
+ position: relative;
+}
+
+[cal-hour] {
+ block-size: 3rem;
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+ font-variant-numeric: tabular-nums;
+ padding-inline-end: var(--rst-sp-2);
+ text-align: end;
+ /* The label sits at the line it names, not in the box below it. */
+ transform: translateY(-0.5em);
+}
+
+/* The day column. 72rem is 24 hours at 3rem each, so an hour label and
+ * the grid line beside it stay in step. */
+[cal-column] {
+ block-size: 72rem;
+ border-inline-end: 1px solid var(--rst-line);
+ position: relative;
+}
+
+[cal-column-head] {
+ border-block-end: 1px solid var(--rst-line);
+ padding: var(--rst-sp-2);
+ position: sticky;
+ text-align: center;
+ top: 0;
+ z-index: 2;
+ background: var(--rst-bg);
+}
+
+[cal-column-head~="today"] { font-weight: 700; }
+
+/* The hour rules, drawn as a repeating background so 24 of them cost
+ * one declaration rather than 24 elements. */
+[cal-column-rules] {
+ background-image: repeating-linear-gradient(
+ to bottom,
+ var(--rst-line) 0,
+ var(--rst-line) 1px,
+ transparent 1px,
+ transparent 3rem
+ );
+ inset: 0;
+ position: absolute;
+}
+
+/* An empty half hour, as a link. This is what makes "click a time to
+ * create an event" work with no JavaScript: 48 ordinary links per day,
+ * each with a screen-reader label naming its time. */
+[cal-slot] {
+ display: block;
+ inset-inline: 0;
+ position: absolute;
+}
+
+[cal-slot]:hover {
+ background: color-mix(in oklab, var(--rst-accent) 10%, transparent);
+}
+
+[cal-slot]:focus-visible {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: -2px;
+}
+
+/* A placed event. top/height/left/width arrive as inline style from
+ * the server's own packer — rastrillo's CSP allows inline styles, so
+ * this needs no exception. */
+[cal-placed] {
+ position: absolute;
+ z-index: 1;
+}
+
+[cal-placed] [cal-chip] {
+ block-size: 100%;
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0;
+ white-space: normal;
+}
+
+/* The all-day strip above the timed grid. All-day events are not
+ * packed into the column: giving a birthday a height would make it
+ * look like a 24-hour meeting. */
+[cal-allday] {
+ border-block-end: 1px solid var(--rst-line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-1);
+ padding: var(--rst-sp-2);
+}
+
+/* ── The whisper ───────────────────────────────────────────────── */
+
+/* An event authored in another zone. The primary time is always the
+ * reader's own; this is a note beside it, never the headline. */
+[cal-elsewhere] {
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+}
+
+/* ── Narrow screens ────────────────────────────────────────────── */
+
+/* At 320px the month reflows to a stacked list of days. A seven-column
+ * grid at that width is seven columns of nothing.
+ *
+ * Week keeps its two dimensions and scrolls sideways: a week view IS a
+ * two-dimensional comparison, and flattening it to a list makes it the
+ * agenda, which already exists. */
+@media (max-width: 40rem) {
+ [cal-month], [cal-month] thead { display: block; }
+ [cal-month] thead { display: none; }
+ [cal-month] tbody, [cal-month] tr { display: block; }
+ [cal-month] td { display: block; height: auto; min-height: 2.5rem; }
+ /* A day with nothing in it is not worth a row of its own on a
+ * phone. */
+ [cal-month] td:not(:has([cal-chip])):not(:has([cal-day-num~="today"])) { display: none; }
+ [cal-day-cell] { flex-direction: row; align-items: baseline; gap: var(--rst-sp-2); }
+
+ [cal-week-scroll] { overflow-x: auto; }
+ [cal-week-grid] { min-inline-size: 48rem; }
+}
+
+/* ── Motion ────────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ [cal-slot], [cal-chip] { transition: none; }
+}
diff --git a/internal/calendar/store_calendar.go b/internal/calendar/store_calendar.go
index 5597136..c9e381b 100644
--- a/internal/calendar/store_calendar.go
+++ b/internal/calendar/store_calendar.go
@@ -200,6 +200,32 @@ func (s *Store) openCalendar(c Calendar, role string) (CalendarView, error) {
return CalendarView{Calendar: c, Name: name, Role: role}, nil
}
+// DefaultCalendarID is where a new event lands when nobody has said
+// otherwise: the person's own calendar, falling back to the first they
+// can write.
+//
+// Personal rather than team, deliberately. Putting a half-formed event
+// on the shared calendar by default means the cost of forgetting to
+// change a dropdown is everybody else seeing it, and that asymmetry
+// should fall on the side of privacy.
+func (s *Store) DefaultCalendarID(ctx context.Context, personID int64) (int64, error) {
+ cals, err := s.Calendars(ctx, personID)
+ if err != nil {
+ return 0, err
+ }
+ for _, c := range cals {
+ if c.Kind == "personal" && c.OwnerID == personID {
+ return c.ID, nil
+ }
+ }
+ for _, c := range cals {
+ if CanWrite(c.Role) {
+ return c.ID, nil
+ }
+ }
+ return 0, nil
+}
+
// Colour is an intent on the hue wheel, resolved to a real pair by the
// theme so it clears the contrast floor in both schemes. teamHue is a
// settled blue; a personal calendar is spread around the wheel by id so
diff --git a/internal/calendar/store_drain.go b/internal/calendar/store_drain.go
new file mode 100644
index 0000000..e3b9d96
--- /dev/null
+++ b/internal/calendar/store_drain.go
@@ -0,0 +1,195 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// DueReminder is everything sending one message needs, gathered in one
+// query so the sender does no further lookups.
+type DueReminder struct {
+ Queue ReminderQueue
+
+ EventID int64
+ Title string
+ Location string
+ Zone string
+ Address string
+ StartUTC int64
+
+ // DayWords is the occurrence's date written out, so the message can
+ // say "Wednesday 2 September" rather than making somebody decode a
+ // timestamp.
+ DayWords string
+}
+
+// claimLease is how long a claimed reminder stays claimed before
+// another pass may take it.
+//
+// It exists because a process killed between claiming and sending would
+// otherwise leave that reminder claimed forever — never sent, and
+// invisible. Five minutes is longer than any send takes and shorter
+// than anybody's patience for a missing reminder. The cost of the lease
+// expiring after a successful send that was not yet marked is one
+// duplicate message, which is the trade this app makes on purpose.
+const claimLease = 5 * time.Minute
+
+// ClaimDueReminder takes the earliest unclaimed due reminder and marks
+// it claimed, returning everything needed to send it.
+//
+// The claim is a conditional UPDATE rather than a read followed by a
+// write: two drains running at once — a tick and a page view — would
+// otherwise both read the same row and both send it.
+func (s *Store) ClaimDueReminder(ctx context.Context, now int64) (DueReminder, bool, error) {
+ cutoff := now - int64(claimLease.Seconds())
+
+ var q ReminderQueue
+ err := s.g.WithContext(ctx).
+ Where("fired_at IS NULL AND due_at <= ?", now).
+ Where("claimed_at IS NULL OR claimed_at < ?", cutoff).
+ Order("due_at ASC").
+ First(&q).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return DueReminder{}, false, nil
+ }
+ if err != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: find due reminder: %w", err)
+ }
+
+ // Claim it, conditional on it still looking the way it did. A
+ // competing drain that got there first changes claimed_at, and this
+ // update then affects no rows and this pass moves on.
+ res := s.g.WithContext(ctx).Model(&ReminderQueue{}).
+ Where("id = ? AND fired_at IS NULL", q.ID).
+ Where("claimed_at IS NULL OR claimed_at < ?", cutoff).
+ Updates(map[string]any{"claimed_at": now})
+ if res.Error != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: claim reminder: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return DueReminder{}, false, nil
+ }
+
+ var rem Reminder
+ if err := s.g.WithContext(ctx).First(&rem, q.ReminderID).Error; err != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: read reminder: %w", err)
+ }
+ var e Event
+ if err := s.g.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", rem.EventID).
+ First(&e).Error; err != nil {
+ // The event went away between queueing and firing. Drop the
+ // row rather than send a reminder for something that no longer
+ // exists.
+ _ = s.g.WithContext(ctx).Delete(&ReminderQueue{}, q.ID).Error
+ return DueReminder{}, false, nil
+ }
+ var p Person
+ if err := s.g.WithContext(ctx).First(&p, rem.PersonID).Error; err != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: read person: %w", err)
+ }
+
+ title, err := s.seal.OpenString(seal.PurposeEventTitle, e.TitleSealed)
+ if err != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: open title for reminder: %w", err)
+ }
+ loc, err := s.seal.OpenString(seal.PurposeEventLocation, e.LocationSealed)
+ if err != nil {
+ return DueReminder{}, false, fmt.Errorf("calendar: open location for reminder: %w", err)
+ }
+
+ start := q.DueAt + int64(rem.OffsetMin)*60
+ zone := p.Zone
+ if zone == "" {
+ zone = defaultZone
+ }
+ words := ""
+ if t, err := InZone(start, zone); err == nil {
+ words = t.Format("Monday 2 January")
+ }
+
+ return DueReminder{
+ Queue: q,
+ EventID: e.ID,
+ Title: title,
+ Location: loc,
+ Zone: zone,
+ Address: p.Address,
+ StartUTC: start,
+ DayWords: words,
+ }, true, nil
+}
+
+// MarkReminderSent records the fire, and how late it was.
+//
+// LateBySec is written rather than discarded because an instance that
+// hibernates fires late sometimes, and a product that hides that is a
+// product whose users conclude reminders are unreliable without ever
+// being told why.
+func (s *Store) MarkReminderSent(ctx context.Context, queueID, now int64) error {
+ var q ReminderQueue
+ if err := s.g.WithContext(ctx).First(&q, queueID).Error; err != nil {
+ return fmt.Errorf("calendar: read queue row: %w", err)
+ }
+ late := now - q.DueAt
+ if late < 0 {
+ late = 0
+ }
+ err := s.g.WithContext(ctx).Model(&ReminderQueue{}).
+ Where("id = ?", queueID).
+ Updates(map[string]any{"fired_at": now, "late_by_sec": late}).Error
+ if err != nil {
+ return fmt.Errorf("calendar: mark reminder sent: %w", err)
+ }
+ return nil
+}
+
+// MaxReminderAttempts is when a reminder stops being retried.
+//
+// Some addresses are permanently undeliverable, and retrying one
+// forever is a queue that never drains and an instance that never
+// hibernates — which costs real money on a platform billed by
+// residency.
+const MaxReminderAttempts = 5
+
+// FailReminder records an attempt and gives up after enough of them.
+func (s *Store) FailReminder(ctx context.Context, queueID int64) error {
+ var q ReminderQueue
+ if err := s.g.WithContext(ctx).First(&q, queueID).Error; err != nil {
+ return fmt.Errorf("calendar: read queue row: %w", err)
+ }
+ attempts := q.Attempts + 1
+ updates := map[string]any{"attempts": attempts, "claimed_at": nil}
+ if attempts >= MaxReminderAttempts {
+ // Marked fired without having been sent: the alternative is a
+ // row that is retried at every wake for the rest of the
+ // instance's life. The settings page lists these as
+ // undeliverable, so it is visible rather than silent.
+ updates["fired_at"] = time.Now().Unix()
+ }
+ err := s.g.WithContext(ctx).Model(&ReminderQueue{}).
+ Where("id = ?", queueID).Updates(updates).Error
+ if err != nil {
+ return fmt.Errorf("calendar: record reminder failure: %w", err)
+ }
+ return nil
+}
+
+// UndeliverableReminders lists the ones that gave up, for the settings
+// page.
+func (s *Store) UndeliverableReminders(ctx context.Context, personID int64) (int64, error) {
+ var n int64
+ err := s.g.WithContext(ctx).Model(&ReminderQueue{}).
+ Joins("JOIN reminders ON reminders.id = reminder_queues.reminder_id").
+ Where("reminders.person_id = ? AND reminder_queues.attempts >= ?", personID, MaxReminderAttempts).
+ Count(&n).Error
+ if err != nil {
+ return 0, fmt.Errorf("calendar: count undeliverable: %w", err)
+ }
+ return n, nil
+}
diff --git a/internal/calendar/store_event.go b/internal/calendar/store_event.go
index 66aa452..243f580 100644
--- a/internal/calendar/store_event.go
+++ b/internal/calendar/store_event.go
@@ -61,6 +61,16 @@ type EventView struct {
// Role is the viewer's grant on the owning calendar.
Role string
+
+ // UIDSealedOpen is the opened iCalendar UID. Populated only where
+ // export needs it — every other view leaves it empty rather than
+ // decrypting a value nothing is going to print.
+ UIDSealedOpen string
+
+ // ReminderOffsetMin is the viewer's own warning on this event, in
+ // minutes before it starts, for a feed that carries alarms. Zero
+ // means none.
+ ReminderOffsetMin int
}
// Writable reports whether the viewer may change this event.
diff --git a/internal/calendar/store_feed.go b/internal/calendar/store_feed.go
new file mode 100644
index 0000000..2e39b95
--- /dev/null
+++ b/internal/calendar/store_feed.go
@@ -0,0 +1,199 @@
+package calendar
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// FeedView is a feed with its poll counts, for the share page.
+type FeedView struct {
+ Feed
+ CalendarName string
+}
+
+// MintFeed creates a subscription capability and returns the token —
+// once.
+//
+// Only the hash is stored. A plaintext token in this table would be a
+// live, unauthenticated read capability for somebody's whole calendar,
+// sitting in a file the platform replicates to object storage — which
+// is exactly what the one rule exists to prevent.
+//
+// The consequence is that the URL cannot be shown again. The share page
+// says so at the moment of minting, rather than letting somebody
+// discover it by coming back for it.
+func (s *Store) MintFeed(ctx context.Context, personID, calendarID int64) (string, error) {
+ if _, err := s.Calendar(ctx, personID, calendarID); err != nil {
+ return "", err
+ }
+ raw := make([]byte, 32)
+ if _, err := rand.Read(raw); err != nil {
+ return "", fmt.Errorf("calendar: no randomness for a feed token: %w", err)
+ }
+ token := hex.EncodeToString(raw)
+
+ f := Feed{
+ PersonID: personID,
+ CalendarID: calendarID,
+ TokenHash: HashFeedToken(token),
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.g.WithContext(ctx).Create(&f).Error; err != nil {
+ return "", fmt.Errorf("calendar: mint feed: %w", err)
+ }
+ return token, nil
+}
+
+// HashFeedToken is the stored form. SHA-256 with no salt, deliberately:
+// the token is 32 bytes of entropy, so there is no dictionary to
+// defend against, and an unsalted hash is what lets a lookup be a
+// single indexed read rather than a scan of every row.
+func HashFeedToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// FeedByToken resolves a subscription URL, and records the poll.
+//
+// The poll count is the honest part. A feed is not free: every poll
+// wakes a hibernating instance, and Apple Calendar polls roughly every
+// fifteen minutes forever. Recording it means the share page can show
+// what a feed actually costs rather than implying it is free.
+func (s *Store) FeedByToken(ctx context.Context, token string) (Feed, error) {
+ var f Feed
+ err := s.g.WithContext(ctx).
+ Where("token_hash = ? AND revoked_at IS NULL", HashFeedToken(token)).
+ First(&f).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Feed{}, ErrNotFound
+ }
+ if err != nil {
+ return Feed{}, fmt.Errorf("calendar: read feed: %w", err)
+ }
+ now := time.Now().UTC()
+ // Best effort: a failed counter update must not fail the response.
+ // Somebody's calendar not loading is a real problem; a poll count
+ // being one short is not.
+ _ = s.g.WithContext(ctx).Model(&Feed{}).Where("id = ?", f.ID).
+ Updates(map[string]any{
+ "last_polled_at": now,
+ "poll_count": gorm.Expr("poll_count + 1"),
+ }).Error
+ return f, nil
+}
+
+// FeedsFor lists a person's own feeds on a calendar.
+func (s *Store) FeedsFor(ctx context.Context, personID, calendarID int64) ([]Feed, error) {
+ var out []Feed
+ err := s.g.WithContext(ctx).
+ Where("person_id = ? AND calendar_id = ? AND revoked_at IS NULL", personID, calendarID).
+ Order("created_at DESC").
+ Find(&out).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: list feeds: %w", err)
+ }
+ return out, nil
+}
+
+// RevokeFeed turns a subscription URL off.
+//
+// Revoked rather than deleted, so the share page can keep showing that
+// it existed and how much it was polled — and so a URL that is still
+// pasted into somebody's phone stops working rather than silently
+// matching a later feed that happened to reuse the row.
+func (s *Store) RevokeFeed(ctx context.Context, personID, feedID int64) error {
+ now := time.Now().UTC()
+ res := s.g.WithContext(ctx).Model(&Feed{}).
+ Where("id = ? AND person_id = ?", feedID, personID).
+ Update("revoked_at", now)
+ if res.Error != nil {
+ return fmt.Errorf("calendar: revoke feed: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// EventsForFeed reads a calendar's events for export, opening the UID
+// and attaching the feed owner's own reminder offsets.
+func (s *Store) EventsForFeed(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
+ all, err := s.EventsInRange(ctx, personID, from, to)
+ if err != nil {
+ return nil, err
+ }
+ offsets, err := s.reminderOffsets(ctx, personID)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]EventView, 0, len(all))
+ for _, e := range all {
+ if e.CalendarID != calendarID {
+ continue
+ }
+ uid, err := s.openUID(e.Event)
+ if err != nil {
+ return nil, err
+ }
+ e.UIDSealedOpen = uid
+ e.ReminderOffsetMin = offsets[e.ID]
+ out = append(out, e)
+ }
+ return out, nil
+}
+
+func (s *Store) openUID(e Event) (string, error) {
+ uid, err := s.seal.OpenString("event.uid", e.UIDSealed)
+ if err != nil {
+ return "", fmt.Errorf("calendar: open uid (event %d): %w", e.ID, err)
+ }
+ return uid, nil
+}
+
+// reminderOffsets maps event id to the person's largest warning on it,
+// for the alarms a feed can carry.
+func (s *Store) reminderOffsets(ctx context.Context, personID int64) (map[int64]int, error) {
+ var rows []Reminder
+ if err := s.g.WithContext(ctx).
+ Where("person_id = ?", personID).Find(&rows).Error; err != nil {
+ return nil, fmt.Errorf("calendar: read reminder offsets: %w", err)
+ }
+ out := map[int64]int{}
+ for _, r := range rows {
+ if r.OffsetMin > out[r.EventID] {
+ out[r.EventID] = r.OffsetMin
+ }
+ }
+ return out, nil
+}
+
+// SaveSettings updates a person's own display preferences.
+func (s *Store) SaveSettings(ctx context.Context, personID int64, zone string, weekStart, reminderMin, allDayMin int) error {
+ if _, err := Zone(zone); err != nil {
+ return ValidationError{"zone", "That is not a time zone we know."}
+ }
+ switch weekStart {
+ case WeekStartMonday, WeekStartSaturday, WeekStartSunday:
+ default:
+ weekStart = WeekStartMonday
+ }
+ err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
+ Updates(map[string]any{
+ "zone": zone,
+ "week_start": weekStart,
+ "default_reminder_min": reminderMin,
+ "default_all_day_min": allDayMin,
+ "updated_at": time.Now().UTC(),
+ }).Error
+ if err != nil {
+ return fmt.Errorf("calendar: save settings: %w", err)
+ }
+ return nil
+}
diff --git a/internal/calendar/store_reminder.go b/internal/calendar/store_reminder.go
new file mode 100644
index 0000000..ffad8cb
--- /dev/null
+++ b/internal/calendar/store_reminder.go
@@ -0,0 +1,275 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// EnsureDefaultReminder gives a newly created event the person's own
+// default warning, using the preference that matches what kind of thing
+// it is.
+//
+// A default rather than nothing, because a reminder somebody has to
+// remember to set is a reminder that exists for the events they were
+// already thinking about. A note gets none: a birthday does not need a
+// ten-minute warning, and the all-day default is the evening before.
+func (s *Store) EnsureDefaultReminder(ctx context.Context, p Person, e Event) error {
+ offset := p.DefaultReminderMin
+ if e.AllDay {
+ offset = p.DefaultAllDayMin
+ }
+ if offset == 0 {
+ return nil
+ }
+ r := Reminder{
+ EventID: e.ID,
+ PersonID: p.ID,
+ OffsetMin: offset,
+ Channel: "email",
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.g.WithContext(ctx).Create(&r).Error; err != nil {
+ return fmt.Errorf("calendar: create reminder: %w", err)
+ }
+ return s.RescheduleReminder(ctx, r.ID)
+}
+
+// RemindersFor lists a person's own reminders on an event. Only their
+// own: a reminder is a fact about a person, and whether a colleague
+// wants a warning is not information the event's page should carry.
+func (s *Store) RemindersFor(ctx context.Context, personID, eventID int64) ([]Reminder, error) {
+ var out []Reminder
+ err := s.g.WithContext(ctx).
+ Where("event_id = ? AND person_id = ?", eventID, personID).
+ Order("offset_min DESC").
+ Find(&out).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: list reminders: %w", err)
+ }
+ return out, nil
+}
+
+// AddReminder attaches a warning to an event the person can see.
+func (s *Store) AddReminder(ctx context.Context, personID, eventID, offsetMin int64) error {
+ if _, err := s.Event(ctx, personID, eventID); err != nil {
+ return err
+ }
+ r := Reminder{
+ EventID: eventID,
+ PersonID: personID,
+ OffsetMin: int(offsetMin),
+ Channel: "email",
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.g.WithContext(ctx).Create(&r).Error; err != nil {
+ return fmt.Errorf("calendar: add reminder: %w", err)
+ }
+ return s.RescheduleReminder(ctx, r.ID)
+}
+
+// RemoveReminder deletes one of the person's own reminders, and the
+// queued row with it.
+func (s *Store) RemoveReminder(ctx context.Context, personID, reminderID int64) error {
+ return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ res := tx.Where("id = ? AND person_id = ?", reminderID, personID).Delete(&Reminder{})
+ if res.Error != nil {
+ return fmt.Errorf("calendar: remove reminder: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ // Somebody else's reminder, or none. Not found, never
+ // forbidden.
+ return ErrNotFound
+ }
+ return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
+ })
+}
+
+// RescheduleReminder recomputes when a rule next fires and writes the
+// single pending queue row for it.
+//
+// One row per rule, always — never one per occurrence. A daily standup
+// that repeats forever has exactly one queued row at any moment, and
+// firing it computes the next. Materialising a series' reminders is how
+// a queue becomes unbounded.
+func (s *Store) RescheduleReminder(ctx context.Context, reminderID int64) error {
+ var rem Reminder
+ if err := s.g.WithContext(ctx).First(&rem, reminderID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil
+ }
+ return fmt.Errorf("calendar: read reminder: %w", err)
+ }
+ var e Event
+ if err := s.g.WithContext(ctx).
+ Where("id = ? AND deleted_at IS NULL", rem.EventID).First(&e).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ // The event is gone; so is any reason to warn about it.
+ return s.g.WithContext(ctx).Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
+ }
+ return fmt.Errorf("calendar: read event for reminder: %w", err)
+ }
+
+ occStart, occLocal, ok := s.nextOccurrenceAfter(e, time.Now().Unix(), rem.OffsetMin)
+ if !ok {
+ // Nothing left to warn about: a finished series, or a single
+ // event already past. Drop the queue row rather than leave a
+ // due time in the past that every drain re-examines.
+ return s.g.WithContext(ctx).Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
+ }
+
+ q := ReminderQueue{
+ ReminderID: reminderID,
+ OccurrenceKey: fmt.Sprintf("%d/%s", e.ID, occLocal),
+ DueAt: occStart - int64(rem.OffsetMin)*60,
+ }
+ // Upsert on the unique reminder_id, so rescheduling is idempotent
+ // and two callers cannot leave two pending rows for one rule.
+ err := s.g.WithContext(ctx).
+ Where(ReminderQueue{ReminderID: reminderID}).
+ Assign(map[string]any{
+ "occurrence_key": q.OccurrenceKey,
+ "due_at": q.DueAt,
+ "claimed_at": nil,
+ "fired_at": nil,
+ "attempts": 0,
+ "late_by_sec": 0,
+ }).
+ FirstOrCreate(&q).Error
+ if err != nil {
+ return fmt.Errorf("calendar: queue reminder: %w", err)
+ }
+ return nil
+}
+
+// nextOccurrenceAfter finds the first occurrence whose reminder has not
+// yet come due.
+//
+// It searches from now minus the offset, so a reminder set for an event
+// starting in five minutes still fires — the due time is in the past,
+// and firing late is the correct answer, not skipping.
+func (s *Store) nextOccurrenceAfter(e Event, now int64, offsetMin int) (int64, string, bool) {
+ if e.RRule == "" {
+ if e.StartUTC-int64(offsetMin)*60 <= now && e.StartUTC <= now {
+ return 0, "", false // already started; nothing to warn about
+ }
+ return e.StartUTC, e.StartLocal, true
+ }
+ rule, err := ParseRule(e.RRule)
+ if err != nil {
+ // A foreign rule still has its own first occurrence.
+ if e.StartUTC > now {
+ return e.StartUTC, e.StartLocal, true
+ }
+ return 0, "", false
+ }
+ // A window wide enough to find the next occurrence of anything
+ // realistic — a yearly series needs more than a year.
+ const lookahead = 400 * 24 * 3600
+ occs := Expand(e.StartLocal, e.EndLocal, e.TZ, e.AllDay, rule, e.Exdates, now-3600, now+lookahead)
+ for _, o := range occs {
+ if o.StartUTC > now {
+ return o.StartUTC, o.Local, true
+ }
+ }
+ return 0, "", false
+}
+
+// NextDueAt is what Options.NextDue answers: the earliest moment this
+// instance has work.
+//
+// The platform asks a running instance this before hibernating it, and
+// wakes it at the answer. That is what makes a reminder fire on a
+// machine that is asleep almost all the time — without it, a reminder
+// would arrive whenever the next person happened to load a page.
+//
+// Zero means nothing is scheduled, and the platform hibernates
+// indefinitely.
+func (s *Store) NextDueAt(ctx context.Context) time.Time {
+ var q ReminderQueue
+ err := s.g.WithContext(ctx).
+ Where("fired_at IS NULL").
+ Order("due_at ASC").
+ First(&q).Error
+ if err != nil {
+ return time.Time{}
+ }
+ return time.Unix(q.DueAt, 0)
+}
+
+// ExcludeOccurrence deletes one occurrence of a series by adding an
+// EXDATE, and reschedules any reminder that pointed at it.
+func (s *Store) ExcludeOccurrence(ctx context.Context, personID, eventID int64, local string) error {
+ e, err := s.Event(ctx, personID, eventID)
+ if err != nil {
+ return err
+ }
+ if !e.Writable() {
+ return ErrReadOnly
+ }
+ set := exdateSet(e.Exdates)
+ if set[local] {
+ return nil
+ }
+ next := e.Exdates
+ if next != "" {
+ next += ","
+ }
+ next += local
+ if err := s.g.WithContext(ctx).Model(&Event{}).Where("id = ?", eventID).
+ Updates(map[string]any{"exdates": next, "updated_at": time.Now().UTC()}).Error; err != nil {
+ return fmt.Errorf("calendar: exclude occurrence: %w", err)
+ }
+ return s.rescheduleAllFor(ctx, eventID)
+}
+
+// rescheduleAllFor recomputes every reminder on an event, after the
+// event's own times or rule changed.
+func (s *Store) rescheduleAllFor(ctx context.Context, eventID int64) error {
+ var ids []int64
+ if err := s.g.WithContext(ctx).Model(&Reminder{}).
+ Where("event_id = ?", eventID).Pluck("id", &ids).Error; err != nil {
+ return fmt.Errorf("calendar: list reminders to reschedule: %w", err)
+ }
+ for _, id := range ids {
+ if err := s.RescheduleReminder(ctx, id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// CountOccurrences reports how many times a series happens, for the
+// delete confirmation — "All 52 of them" is a warning that a bare
+// "Delete all" is not.
+//
+// forever is true for a series with no end, where no count exists and
+// the confirmation says so instead of inventing a number.
+func (s *Store) CountOccurrences(e EventView) (int, bool) {
+ if e.RRule == "" {
+ return 1, false
+ }
+ rule, err := ParseRule(e.RRule)
+ if err != nil {
+ return 0, true
+ }
+ if rule.Count > 0 {
+ return rule.Count, false
+ }
+ if !rule.HasUntil {
+ return 0, true
+ }
+ occs := Expand(e.StartLocal, e.EndLocal, e.TZ, e.AllDay, rule, e.Exdates,
+ e.StartUTC-1, untilBound(rule))
+ return len(occs), false
+}
+
+func untilBound(r Rule) int64 {
+ if r.HasUntil {
+ return r.Until.Unix() + 24*3600
+ }
+ return time.Now().AddDate(2, 0, 0).Unix()
+}
diff --git a/internal/calendar/summary.go b/internal/calendar/summary.go
new file mode 100644
index 0000000..e30c29d
--- /dev/null
+++ b/internal/calendar/summary.go
@@ -0,0 +1,185 @@
+package calendar
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// DaySummary is the sentence at the top of Today, and the one piece of
+// this app that has an opinion.
+//
+// A calendar usually answers "what is on" and leaves "how full is this"
+// to the reader's eye on a grid. But the feeling a day produces is not
+// a list — it is whether there is room in it — and that is arithmetic
+// the server already has. So Today opens by saying it:
+//
+// Wednesday. Four things; the first at 09:30, the last ends at 17:00.
+// Two hours and fifteen minutes unbooked between them.
+//
+// The unbooked figure is the gap BETWEEN the first start and the last
+// end, not the whole day: hours before your first meeting are not a
+// gap, they are the morning. And when there are none:
+//
+// There is no unbooked hour today.
+//
+// which is a true and slightly alarming sentence, said plainly, because
+// that is the day the person is actually having.
+type DaySummary struct {
+ // Weekday is the day's own name, so the sentence can open with it.
+ Weekday string
+
+ // Count is how many things occupy time. Notes are excluded: a
+ // birthday does not make a Tuesday fuller.
+ Count int
+
+ FirstStart string // "09:30", empty when nothing is timed
+ LastEnd string // "17:00"
+
+ // Unbooked is the free time between the first start and the last
+ // end, in minutes. Meaningless when Count < 2.
+ UnbookedMin int
+
+ // Sentence is the whole thing, assembled. Templates print this
+ // rather than reassembling the parts, so the wording lives in one
+ // place and can be read as prose in review.
+ Sentence string
+}
+
+// SummariseDay builds the sentence for one day's events.
+func SummariseDay(day time.Time, events []EventView, dayStart, dayEnd int64, zone string) DaySummary {
+ s := DaySummary{Weekday: day.Format("Monday")}
+
+ var busy []EventView
+ for _, e := range events {
+ if Busy(e.Kind) && !e.AllDay {
+ busy = append(busy, e)
+ }
+ }
+ s.Count = len(busy)
+
+ if len(busy) == 0 {
+ s.Sentence = fmt.Sprintf("%s. Nothing booked.", s.Weekday)
+ return s
+ }
+
+ sortOccurrences(busy)
+ first := clamp(busy[0].OccStartUTC, dayStart, dayEnd)
+ last := first
+ for _, e := range busy {
+ if end := clamp(e.OccEndUTC, dayStart, dayEnd); end > last {
+ last = end
+ }
+ }
+ s.FirstStart = hhmm(first, zone)
+ s.LastEnd = hhmm(last, zone)
+
+ // Booked minutes, counting overlapping meetings once — two things
+ // at the same time do not consume two hours of a day.
+ booked := mergedSeconds(busy, dayStart, dayEnd)
+ s.UnbookedMin = int((last - first - booked) / 60)
+ if s.UnbookedMin < 0 {
+ s.UnbookedMin = 0
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s. %s; the first at %s, the last ends at %s.",
+ s.Weekday, countThings(s.Count), s.FirstStart, s.LastEnd)
+ if s.Count > 1 {
+ if s.UnbookedMin == 0 {
+ b.WriteString(" There is no unbooked hour between them.")
+ } else {
+ fmt.Fprintf(&b, " That leaves %s unbooked between them.", humanDuration(s.UnbookedMin))
+ }
+ }
+ s.Sentence = b.String()
+ return s
+}
+
+// mergedSeconds is the union of the events' spans — overlapping
+// meetings counted once. Input need not be sorted.
+func mergedSeconds(events []EventView, lo, hi int64) int64 {
+ sorted := append([]EventView(nil), events...)
+ sortOccurrences(sorted)
+
+ var total int64
+ var curStart, curEnd int64 = -1, -1
+ for _, e := range sorted {
+ s, en := clamp(e.OccStartUTC, lo, hi), clamp(e.OccEndUTC, lo, hi)
+ if en <= s {
+ continue
+ }
+ if curStart < 0 {
+ curStart, curEnd = s, en
+ continue
+ }
+ if s > curEnd {
+ total += curEnd - curStart
+ curStart, curEnd = s, en
+ continue
+ }
+ if en > curEnd {
+ curEnd = en
+ }
+ }
+ if curStart >= 0 {
+ total += curEnd - curStart
+ }
+ return total
+}
+
+func countThings(n int) string {
+ if n == 1 {
+ return "One thing"
+ }
+ return fmt.Sprintf("%s things", spellSmall(n))
+}
+
+// spellSmall writes the numbers a day realistically holds as words, and
+// falls back to digits past that. "Seventeen things" reads as a
+// flourish; "17 things" reads as a warning, which by then it is.
+func spellSmall(n int) string {
+ words := []string{"Zero", "One", "Two", "Three", "Four", "Five",
+ "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"}
+ if n >= 0 && n < len(words) {
+ return words[n]
+ }
+ return fmt.Sprintf("%d", n)
+}
+
+// humanDuration writes a gap the way a person would say it, in lower
+// case — the sentence that uses it supplies its own opening, so this
+// never has to start one and never has to begin with a digit.
+func humanDuration(min int) string {
+ h, m := min/60, min%60
+ switch {
+ case h == 0:
+ return pluralMinutes(m)
+ case m == 0:
+ return pluralHours(h)
+ default:
+ return pluralHours(h) + " and " + pluralMinutes(m)
+ }
+}
+
+func pluralHours(h int) string {
+ if h == 1 {
+ return "one hour"
+ }
+ return fmt.Sprintf("%s hours", strings.ToLower(spellSmall(h)))
+}
+
+func pluralMinutes(m int) string {
+ if m == 1 {
+ return "one minute"
+ }
+ return fmt.Sprintf("%d minutes", m)
+}
+
+func hhmm(unix int64, zone string) string {
+ t, err := InZone(unix, zone)
+ if err != nil {
+ return ""
+ }
+ return t.Format("15:04")
+}
diff --git a/internal/calendar/templates/_chip.html b/internal/calendar/templates/_chip.html
new file mode 100644
index 0000000..cf5783c
--- /dev/null
+++ b/internal/calendar/templates/_chip.html
@@ -0,0 +1,19 @@
+{{/* chip — one event, wherever it appears.
+
+ The variant tokens carry meaning that must survive greyscale and a
+ stylesheet that failed to load, so each is ALSO said in text: a
+ past event's time reads "ended 09:30", a cancelled one prints the
+ word, and a note prints "All day" rather than relying on its
+ outline. Colour is never the only channel.
+
+ --cal-hue is the owning calendar's colour intent. The stylesheet
+ mixes it against a theme token rather than using it raw, so a
+ calendar's colour cannot produce an unreadable chip. */}}
+{{define "chip"}}
+<a cal-chip="{{.Kind}}{{if .Past}} past{{end}}{{if eq .Event.Status "cancelled"}} cancelled{{end}}{{if eq .Event.Status "tentative"}} tentative{{end}}"
+ style="--cal-hue: {{.Hue}}"
+ href="{{.Href}}">
+ <span cal-chip-time>{{.TimeLabel}}</span>
+ <span cal-chip-title>{{if eq .Event.Status "cancelled"}}Cancelled: {{end}}{{.Event.Title}}</span>
+</a>
+{{end}}
diff --git a/internal/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html
new file mode 100644
index 0000000..50a1aa4
--- /dev/null
+++ b/internal/calendar/templates/agenda.html
@@ -0,0 +1,36 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Agenda</h1>
+ <p rst-page-header-sub>The next fortnight, and only the days with something in them.</p>
+ </div>
+ <a rst-btn="primary" href="/events/new">New event</a>
+</header>
+
+<div cal-bar>
+ <a rst-btn href="/agenda?from={{.Data.PrevISO}}">Previous fortnight</a>
+ <a rst-btn href="/agenda">From today</a>
+ <a rst-btn href="/agenda?from={{.Data.NextISO}}">Next fortnight</a>
+</div>
+
+{{if .Data.Agenda.Days}}
+{{range .Data.Agenda.Days}}
+<section rst-box>
+ <header rst-box-head>
+ <h2><a href="{{.Href}}">{{.Title}}</a>{{if .Today}} — today{{end}}</h2>
+ </header>
+ <div style="display:flex;flex-direction:column;gap:var(--rst-sp-1);padding:var(--rst-sp-3)">
+ {{range .Chips}}{{template "chip" .}}{{end}}
+ </div>
+</section>
+{{end}}
+{{else}}
+{{/* An empty state that teaches. "No events found" earns nothing. */}}
+{{template "empty-state" dict
+ "Title" "Nothing in the next fortnight"
+ "Body" "Days with something on them appear here, in order. Days with nothing are left out."
+ "ActionHref" "/events/new"
+ "ActionLabel" "Add an event"}}
+{{end}}
+{{end}}
diff --git a/internal/calendar/templates/calendars.html b/internal/calendar/templates/calendars.html
new file mode 100644
index 0000000..1a86385
--- /dev/null
+++ b/internal/calendar/templates/calendars.html
@@ -0,0 +1,24 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Calendars</h1>
+ </div>
+</header>
+
+<div rst-list>
+ {{range .Data.Calendars}}
+ <div rst-row>
+ <span rst-row-lead aria-hidden="true"
+ style="background: hsl({{.Hue}} 55% 45%); border-radius: var(--rst-radius-pill); inline-size: 12px; block-size: 12px;"></span>
+ <span rst-row-main>
+ <a href="/calendars/{{.ID}}/share">{{.Name}}</a>
+ {{/* The role is said in words. Colour and position carry none of
+ it — a viewer needs to know they cannot edit before they try. */}}
+ <small rst-row-sub>{{if eq .Kind "team"}}Shared with the team{{else}}Yours{{end}} · you can {{if canWrite .Role}}edit{{else}}read{{end}}</small>
+ </span>
+ <a rst-row-action href="/calendars/{{.ID}}.ics">Export</a>
+ </div>
+ {{end}}
+</div>
+{{end}}
diff --git a/internal/calendar/templates/day.html b/internal/calendar/templates/day.html
new file mode 100644
index 0000000..7189eb5
--- /dev/null
+++ b/internal/calendar/templates/day.html
@@ -0,0 +1,63 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "main"}}
+<main rst-page id="main">
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Day.Title}}</h1>
+ </div>
+ <a rst-btn="primary" href="/events/new?date={{.Data.Day.ISO}}">New event</a>
+</header>
+
+{{/* The day's own sentence. Not a caption — it is the answer to the
+ question somebody opened this page to ask. */}}
+<p cal-sentence>{{.Data.Day.Summary.Sentence}}</p>
+
+<div cal-bar>
+ <a rst-btn href="/day/{{.Data.PrevISO}}">Previous day</a>
+ <a rst-btn href="/day/{{.Data.TodayISO}}">Today</a>
+ <a rst-btn href="/day/{{.Data.NextISO}}">Next day</a>
+ <span cal-bar-spacer></span>
+ <a rst-btn="ghost" href="/week/{{.Data.Day.ISO}}">Week</a>
+ <a rst-btn="ghost" href="/month/{{slice .Data.Day.ISO 0 7}}">Month</a>
+ <a rst-btn="ghost" href="/agenda?from={{.Data.Day.ISO}}">Agenda</a>
+</div>
+
+{{if .Data.Day.AllDay}}
+<div cal-allday>
+ {{range .Data.Day.AllDay}}{{template "chip" .}}{{end}}
+</div>
+{{end}}
+
+<div cal-grid>
+ <div cal-hours>
+ {{range .Data.Day.Hours}}<div cal-hour>{{.}}</div>{{end}}
+ </div>
+ <div cal-column>
+ <div cal-column-rules aria-hidden="true"></div>
+
+ {{/* Every empty half hour is an ordinary link to a prefilled form.
+ This is the whole of "click a time to create an event" with no
+ JavaScript: the visible target is a box, and the accessible
+ name is the sentence in Label. */}}
+ {{range .Data.Day.Slots}}
+ <a cal-slot href="{{.Href}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%">
+ <span class="rst-sr-only">{{.Label}}</span>
+ </a>
+ {{end}}
+
+ {{range .Data.Day.Placed}}
+ <div cal-placed style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%">
+ {{template "chip" .Chip}}
+ </div>
+ {{end}}
+ </div>
+</div>
+
+{{if not .Data.Day.Placed}}{{if not .Data.Day.AllDay}}
+{{template "empty-state" dict
+ "Title" "Nothing on this day"
+ "Body" "Pick a time in the grid above, or use New event."}}
+{{end}}{{end}}
+</main>
+{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html
new file mode 100644
index 0000000..d67e3b5
--- /dev/null
+++ b/internal/calendar/templates/event.html
@@ -0,0 +1,88 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Event.Title}}</h1>
+ <p rst-page-header-sub>{{.Data.Event.CalendarName}}</p>
+ </div>
+ {{if .Data.Event.Writable}}
+ <a rst-btn="primary" href="/events/{{.Data.Event.ID}}/edit">Edit</a>
+ {{end}}
+</header>
+
+{{if eq .Data.Event.Status "cancelled"}}
+{{template "callout" dict "Tone" "warning" "Body" "This event is cancelled. It stays on the calendar, struck through, so nobody wonders whether it was ever there."}}
+{{end}}
+
+{{$items := list
+ (dict "Label" "When" "Value" .Data.Chip.TimeLabel)
+ (dict "Label" "Starts" "Value" .Data.Event.StartLocal)
+ (dict "Label" "Ends" "Value" .Data.Event.EndLocal)}}
+{{template "detail-list" dict "Items" $items}}
+
+{{/* The authored zone is a whisper, never the headline: the time above
+ is always the reader's own. */}}
+{{if .Data.Chip.AuthoredZone}}
+<p cal-elsewhere>Written as {{.Data.Chip.AuthoredTime}} in {{.Data.Chip.AuthoredZone}}.</p>
+{{end}}
+
+{{with .Data.Event.Location}}<p><strong>Where:</strong> {{.}}</p>{{end}}
+{{with .Data.Event.Notes}}<p>{{.}}</p>{{end}}
+
+{{with .Data.RuleText}}
+<section rst-box>
+ <header rst-box-head><h2>Repeats</h2></header>
+ <div style="padding:var(--rst-sp-3)">
+ <p>{{.}}</p>
+ {{if $.Data.Event.RRuleForeign}}
+ {{template "callout" dict "Tone" "info" "Body" "This rule came from another calendar and is outside the set this app can edit. It is kept exactly as it arrived, and exported unchanged."}}
+ {{end}}
+ </div>
+</section>
+{{end}}
+
+<section rst-box>
+ <header rst-box-head><h2>Your reminders</h2></header>
+ <div style="padding:var(--rst-sp-3)">
+ {{/* Yours only. Whether a colleague wants a warning is a fact
+ about them, and not something this page should carry. */}}
+ {{if .Data.Reminders}}
+ {{range .Data.Reminders}}
+ <form method="post" action="/events/{{$.Data.Event.ID}}/reminders/remove"
+ style="display:flex;align-items:center;gap:var(--rst-sp-2)">
+ <input type="hidden" name="reminder_id" value="{{.ID}}">
+ <span rst-grow>Email {{minutesLabel .OffsetMin}}</span>
+ <button rst-btn="ghost compact" type="submit">Remove</button>
+ </form>
+ {{end}}
+ {{else}}
+ <p>No reminder for this one.</p>
+ {{end}}
+
+ <form method="post" action="/events/{{.Data.Event.ID}}/reminders"
+ style="display:flex;align-items:flex-end;gap:var(--rst-sp-2);margin-top:var(--rst-sp-3)">
+ {{template "field-select" dict
+ "ID" "offset_min" "Name" "offset_min" "Label" "Remind me" "Short" true
+ "Options" (list
+ (dict "Value" "0" "Label" "At the time it starts")
+ (dict "Value" "5" "Label" "5 minutes before")
+ (dict "Value" "10" "Label" "10 minutes before" "Selected" true)
+ (dict "Value" "30" "Label" "30 minutes before")
+ (dict "Value" "60" "Label" "1 hour before")
+ (dict "Value" "1440" "Label" "1 day before"))}}
+ <button rst-btn type="submit">Add</button>
+ </form>
+ </div>
+</section>
+
+<div cal-bar>
+ <a rst-btn="ghost" href="/events/{{.Data.Event.ID}}.ics">Download as .ics</a>
+ {{if .Data.Event.Writable}}
+ {{/* The destructive action is a link to its own page, never a
+ modal: it works with no script, it can be linked to, and the
+ browser's back button is a working cancel. */}}
+ <a rst-btn="ghost" class="rst-danger"
+ href="/events/{{.Data.Event.ID}}/delete{{if .Data.Occurrence}}?occurrence={{.Data.Occurrence}}{{end}}">Delete</a>
+ {{end}}
+</div>
+{{end}}
diff --git a/internal/calendar/templates/event_delete.html b/internal/calendar/templates/event_delete.html
new file mode 100644
index 0000000..1ac6a78
--- /dev/null
+++ b/internal/calendar/templates/event_delete.html
@@ -0,0 +1,41 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Delete “{{.Data.Event.Title}}”?</h1>
+ </div>
+</header>
+
+{{if .Data.Series}}
+{{/* One question, and the expensive answer is LAST with its count
+ said out loud. "All of them" as a default, or as the first radio,
+ is how somebody deletes a year of standups meaning to delete
+ tomorrow's. */}}
+<form rst-form method="post" action="/events/{{.Data.Event.ID}}/delete">
+ <input type="hidden" name="occurrence" value="{{.Data.Occurrence}}">
+ {{template "choice-field" dict
+ "Legend" "This repeats. What should go?"
+ "Name" "scope"
+ "Type" "radio"
+ "Options" (list
+ (dict "Value" "this" "Title" "Just this one"
+ "Desc" "The rest of the series stays exactly as it is."
+ "Checked" true)
+ (dict "Value" "all"
+ "Title" (deleteAllLabel .Data.Count .Data.Forever)
+ "Desc" "Every occurrence, past and future. This cannot be undone."))}}
+ {{template "form-foot" dict
+ "Submit" "Delete"
+ "Danger" true
+ "CancelHref" (printf "/events/%d" .Data.Event.ID)
+ "CancelLabel" "Keep it"}}
+</form>
+{{else}}
+{{template "confirm-form" dict
+ "Action" (printf "/events/%d/delete" .Data.Event.ID)
+ "Label" "Delete this event"
+ "Danger" true
+ "CancelHref" (printf "/events/%d" .Data.Event.ID)
+ "CancelLabel" "Keep it"}}
+{{end}}
+{{end}}
diff --git a/internal/calendar/templates/event_form.html b/internal/calendar/templates/event_form.html
new file mode 100644
index 0000000..0e4ea7d
--- /dev/null
+++ b/internal/calendar/templates/event_form.html
@@ -0,0 +1,83 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Heading}}</h1>
+ </div>
+</header>
+
+{{with .Data.Error}}
+{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}}
+{{end}}
+
+<form rst-form method="post" action="{{.Data.Action}}">
+ {{template "field" dict
+ "ID" "title" "Name" "title" "Label" "What is it?"
+ "Value" .Data.EventTitle "Required" true "Autofocus" true
+ "Error" (if_eq .Data.ErrorField "title" .Data.Error)}}
+
+ {{/* Three kinds, one radio group, and the descriptions say what the
+ difference actually is — because "meeting / commitment / note"
+ without them is three words nobody can choose between. They
+ differ in reminder defaults, in whether they count toward the
+ day's booked hours, and in how they draw. */}}
+ {{template "choice-field" dict
+ "Legend" "What kind of thing?"
+ "Name" "kind"
+ "Type" "radio"
+ "Options" (list
+ (dict "Value" "meeting" "Title" "A meeting"
+ "Desc" "Other people are in it. Being late matters."
+ "Checked" (eq .Data.Kind "meeting"))
+ (dict "Value" "commitment" "Title" "A commitment"
+ "Desc" "Time you have promised yourself. It fills the day, but only you are inconvenienced when it moves."
+ "Checked" (eq .Data.Kind "commitment"))
+ (dict "Value" "note" "Title" "A note"
+ "Desc" "True of the day without occupying any of it — a birthday, a deadline, a bin collection."
+ "Checked" (eq .Data.Kind "note")))}}
+
+ {{template "field-check" dict
+ "Name" "all_day" "Value" "1" "Label" "All day" "Checked" .Data.AllDay}}
+
+ {{/* datetime.js enhances these two into a picker with
+ natural-language parsing. Without it they are ordinary
+ datetime-local inputs, which is a working floor. */}}
+ {{template "field-datetime" dict
+ "Name" "start" "Label" "Starts" "Value" .Data.StartLocal "Required" true
+ "Error" (if_eq .Data.ErrorField "start" .Data.Error)}}
+ {{template "field-datetime" dict
+ "Name" "end" "Label" "Ends" "Value" .Data.EndLocal "Required" true
+ "Hint" "The end is exclusive: an all-day event on the 2nd ends on the 3rd."
+ "Error" (if_eq .Data.ErrorField "end" .Data.Error)}}
+
+ {{template "field-select" dict
+ "ID" "repeat" "Name" "repeat" "Label" "Repeats"
+ "Options" (repeatOptions .Data.StartLocal .Data.TZ .Data.AllDay .Data.Repeat)}}
+
+ {{template "field-date" dict
+ "Name" "repeat_end" "Label" "Repeat until" "Value" .Data.RepeatEnd
+ "Hint" "Leave empty for a series with no end."}}
+
+ {{template "field" dict
+ "ID" "location" "Name" "location" "Label" "Where" "Value" .Data.Location}}
+
+ {{template "field-textarea" dict
+ "Name" "notes" "Label" "Notes" "Value" .Data.Notes "Rows" 4}}
+
+ {{template "field-select" dict
+ "ID" "calendar_id" "Name" "calendar_id" "Label" "Calendar"
+ "Options" (calendarOptions .Data.Calendars .Data.CalendarID)}}
+
+ {{template "field-select" dict
+ "ID" "status" "Name" "status" "Label" "Status"
+ "Options" (list
+ (dict "Value" "confirmed" "Label" "Confirmed" "Selected" (eq .Data.Status "confirmed"))
+ (dict "Value" "tentative" "Label" "Tentative" "Selected" (eq .Data.Status "tentative"))
+ (dict "Value" "cancelled" "Label" "Cancelled" "Selected" (eq .Data.Status "cancelled")))}}
+
+ {{template "form-foot" dict
+ "Submit" .Data.Submit
+ "CancelHref" (eventCancelHref .Data.EventID)
+ "CancelLabel" "Cancel"}}
+</form>
+{{end}}
diff --git a/internal/calendar/templates/import.html b/internal/calendar/templates/import.html
new file mode 100644
index 0000000..8204eed
--- /dev/null
+++ b/internal/calendar/templates/import.html
@@ -0,0 +1,34 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Import a calendar</h1>
+ <p rst-page-header-sub>An .ics file exported from another calendar.</p>
+ </div>
+</header>
+
+{{with .Data.Error}}
+{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}}
+{{end}}
+
+{{/* Said before the upload, not after: what this app does not yet
+ model is exactly what somebody importing a working calendar will
+ miss, and finding out afterwards is worse. */}}
+{{template "callout" dict "Tone" "info"
+ "Title" "What comes across, and what does not"
+ "Body" "Events, times, zones, notes and repeat rules all come across, including rules this app cannot itself create — those are kept exactly as they arrived. Guests and invitations do not: this app does not model them yet, and the report will say how many were dropped."}}
+
+<form rst-form method="post" action="/import" enctype="multipart/form-data">
+ {{template "field-select" dict
+ "ID" "calendar_id" "Name" "calendar_id" "Label" "Import into"
+ "Options" (calendarOptions .Data.Calendars 0)}}
+
+ <div rst-field>
+ <label rst-field-label for="file">The file</label>
+ <input rst-input id="file" name="file" type="file" accept=".ics,text/calendar" required>
+ <small rst-field-hint>Up to 10 MB.</small>
+ </div>
+
+ {{template "form-foot" dict "Submit" "Import" "CancelHref" "/today" "CancelLabel" "Cancel"}}
+</form>
+{{end}}
diff --git a/internal/calendar/templates/import_done.html b/internal/calendar/templates/import_done.html
new file mode 100644
index 0000000..cf18760
--- /dev/null
+++ b/internal/calendar/templates/import_done.html
@@ -0,0 +1,41 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Imported</h1>
+ </div>
+ <a rst-btn="primary" href="/today">Go to today</a>
+</header>
+
+{{/* Every number here is one somebody can act on, and each is named
+ rather than summed into "some events could not be imported". */}}
+{{$items := list
+ (dict "Label" "Events added" "Value" (printf "%d" .Data.Result.Created))
+ (dict "Label" "Skipped" "Value" (printf "%d" .Data.Result.Skipped))}}
+{{template "detail-list" dict "Items" $items}}
+
+{{if .Data.Result.Floating}}
+{{template "callout" dict "Tone" "warning"
+ "Title" "Some events carry no time zone"
+ "Body" "They were kept exactly as written, which means they read as the same wall-clock time wherever they are opened. On a shared calendar that can mean two people seeing two different moments. Editing one gives it your zone."}}
+{{end}}
+
+{{if .Data.Result.ForeignRules}}
+{{template "callout" dict "Tone" "info"
+ "Title" "Some repeat rules are outside what this app builds"
+ "Body" "They were stored exactly as they arrived and export unchanged, but their future occurrences are not drawn on the calendar. The event's own page shows the rule."}}
+{{end}}
+
+{{if .Data.Result.Attendees}}
+{{template "callout" dict "Tone" "info"
+ "Title" "Guests were not imported"
+ "Body" "This app does not model guests or invitations yet, so those lines were dropped. The events themselves came across intact."}}
+{{end}}
+
+{{if .Data.Result.UnknownZones}}
+{{template "callout" dict "Tone" "warning"
+ "Title" "Some time zones could not be resolved"
+ "Body" "Those events were placed in your own zone instead of being dropped. Check them: they may be an hour or two out."}}
+<ul>{{range .Data.Result.UnknownZones}}<li class="rst-mono">{{.}}</li>{{end}}</ul>
+{{end}}
+{{end}}
diff --git a/internal/calendar/templates/index.html b/internal/calendar/templates/index.html
deleted file mode 100644
index 7706037..0000000
--- a/internal/calendar/templates/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}
-<header rst-page-header>
- <div rst-page-header-titles>
- <h1>{{.Title}}</h1>
- </div>
-</header>
-{{template "empty-state" dict
- "Title" "Nothing here yet"
- "Body" "The calendar itself lands on this screen."}}
-{{end}}
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index 28fa126..140c583 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -20,6 +20,9 @@
more stylesheet. Empty by default, last in the head so an app's
own CSS wins over the framework's, and the only block in the
three shells that is not chrome. */}}
+{{/* Last in the head, so this app's own CSS wins the ties it should
+ against tokens.css and the theme. */}}
+<link rel="stylesheet" href="{{asset "static/calendar.css"}}">
{{block "head" .}}{{end}}
</head>
<body>
@@ -64,7 +67,14 @@
meet it. */}}
<details rst-shell-menu name="rst-shell-menu"><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details>
<div rst-shell-tail>
-<nav rst-shell-nav>{{block "nav" .}}{{end}}</nav>
+<nav rst-shell-nav>{{block "nav" .}}
+{{if .SignedIn}}
+<a href="/today">Today</a>
+<a href="/agenda">Agenda</a>
+<a href="/calendars">Calendars</a>
+<a href="/events/new">New event</a>
+{{end}}
+{{end}}</nav>
<details rst-dropdown rst-shell-account name="rst-menus">
<summary>{{T "rastrillo.ui.shell_account"}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
{{/* account here is the dropdown's MENU BODY, not the whole menu: the
@@ -98,6 +108,8 @@
</form>
{{if .SignedIn}}
+<p rst-menu-group><a href="/settings">Settings</a></p>
+<p rst-menu-group><a href="/import">Import a calendar</a></p>
<form rst-menu-group method="post" action="/signout">
<button rst-btn="ghost" type="submit">Sign out</button>
</form>
@@ -107,9 +119,17 @@
{{block "locale" .}}{{end}}
</div>
</header>
-<main rst-page id="main">
+{{/* Two extension points, not one. Most pages define "content" and sit
+ inside rst-page's 64rem reading column, which is right for a form
+ or a list.
+
+ A week grid is not a reading column: it is seven columns that need
+ the viewport. Those pages override "main" and supply their own
+ <main>, which is why this is a block rather than a bare template
+ call. Sheets' layout does the same thing for the same reason. */}}
+{{block "main" .}}<main rst-page id="main">
{{template "content" .}}
-</main>
+</main>{{end}}
<footer rst-shell-foot>{{block "foot" .}}{{end}}</footer>
</div>
</body>
diff --git a/internal/calendar/templates/month.html b/internal/calendar/templates/month.html
new file mode 100644
index 0000000..71a2771
--- /dev/null
+++ b/internal/calendar/templates/month.html
@@ -0,0 +1,51 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "main"}}
+<main id="main" style="padding: var(--rst-sp-4)">
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Month.Title}}</h1>
+ </div>
+ <a rst-btn="primary" href="/events/new">New event</a>
+</header>
+
+<div cal-bar>
+ <a rst-btn href="/month/{{.Data.Month.PrevISO}}">Previous month</a>
+ <a rst-btn href="/today">Today</a>
+ <a rst-btn href="/month/{{.Data.Month.NextISO}}">Next month</a>
+ <span cal-bar-spacer></span>
+ <a rst-btn="ghost" href="/agenda">Agenda</a>
+</div>
+
+{{/* A real table. A month IS tabular data — seven weekday columns
+ against week rows — so the headers come free for a screen reader,
+ and a scriptless page keeps semantics a div grid would have to
+ rebuild with ARIA. */}}
+<table cal-month>
+ <caption class="rst-sr-only">{{.Data.Month.Title}}, by week</caption>
+ <thead>
+ <tr>{{range .Data.Month.Weekdays}}<th scope="col">{{.}}</th>{{end}}</tr>
+ </thead>
+ <tbody>
+ {{range .Data.Month.Weeks}}
+ <tr>
+ {{range .Days}}
+ <td>
+ <div cal-day-cell="{{if not .InMonth}}outside{{end}}{{if .Weekend}} weekend{{end}}">
+ <a cal-day-num="{{if .IsToday}}today{{end}}" href="{{.Href}}">
+ {{.Day}}{{if .IsToday}}<span class="rst-sr-only"> (today)</span>{{end}}
+ </a>
+ {{range .Chips}}{{template "chip" .}}{{end}}
+ {{/* A month cell that grew to fit its fullest day would make
+ every row that tall. The overflow links to the day, which
+ is the only place that can show them all. */}}
+ {{if .More}}<a cal-more href="{{.Href}}">+{{.More}} more</a>{{end}}
+ </div>
+ </td>
+ {{end}}
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+</main>
+{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/calendar/templates/settings.html b/internal/calendar/templates/settings.html
new file mode 100644
index 0000000..9d89e93
--- /dev/null
+++ b/internal/calendar/templates/settings.html
@@ -0,0 +1,53 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Settings</h1>
+ </div>
+</header>
+
+{{if .Data.Saved}}
+{{template "callout" dict "Tone" "positive" "Body" "Saved."}}
+{{end}}
+{{with .Data.Error}}
+{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}}
+{{end}}
+
+{{if .Data.Undeliverable}}
+{{/* Stated rather than hidden. A reminder that gave up is a reminder
+ somebody is still expecting. */}}
+{{template "callout" dict "Tone" "warning"
+ "Title" "Some reminders could not be delivered"
+ "Body" "They were retried and then given up on, so they will not arrive. Check that this address accepts mail from this instance."}}
+{{end}}
+
+<form rst-form method="post" action="/settings">
+ {{template "field-select" dict
+ "ID" "zone" "Name" "zone" "Label" "Your time zone"
+ "Options" (zoneOptions .Data.Person.Zone)
+ "Hint" "Every time on the calendar is shown in this zone. It is never guessed from your browser: a calendar that silently re-times itself because you opened it on holiday is a calendar that lies."}}
+
+ {{template "field-select" dict
+ "ID" "week_start" "Name" "week_start" "Label" "Weeks start on"
+ "Options" (weekStartOptions .Data.Person.WeekStart)}}
+
+ {{template "field-select" dict
+ "ID" "default_reminder_min" "Name" "default_reminder_min" "Label" "Remind me about timed events"
+ "Options" (list
+ (dict "Value" "0" "Label" "Not by default" "Selected" (eq .Data.Person.DefaultReminderMin 0))
+ (dict "Value" "5" "Label" "5 minutes before" "Selected" (eq .Data.Person.DefaultReminderMin 5))
+ (dict "Value" "10" "Label" "10 minutes before" "Selected" (eq .Data.Person.DefaultReminderMin 10))
+ (dict "Value" "30" "Label" "30 minutes before" "Selected" (eq .Data.Person.DefaultReminderMin 30))
+ (dict "Value" "60" "Label" "1 hour before" "Selected" (eq .Data.Person.DefaultReminderMin 60)))}}
+
+ {{template "field-select" dict
+ "ID" "default_all_day_min" "Name" "default_all_day_min" "Label" "Remind me about all-day events"
+ "Hint" "A birthday you hear about at midnight is a birthday you have half missed."
+ "Options" (list
+ (dict "Value" "0" "Label" "Not by default" "Selected" (eq .Data.Person.DefaultAllDayMin 0))
+ (dict "Value" "360" "Label" "The evening before" "Selected" (eq .Data.Person.DefaultAllDayMin 360))
+ (dict "Value" "1440" "Label" "A day before" "Selected" (eq .Data.Person.DefaultAllDayMin 1440)))}}
+
+ {{template "form-foot" dict "Submit" "Save" "CancelHref" "/today" "CancelLabel" "Cancel"}}
+</form>
+{{end}}
diff --git a/internal/calendar/templates/share.html b/internal/calendar/templates/share.html
new file mode 100644
index 0000000..dd21c2c
--- /dev/null
+++ b/internal/calendar/templates/share.html
@@ -0,0 +1,52 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Calendar.Name}}</h1>
+ <p rst-page-header-sub>Subscription links</p>
+ </div>
+</header>
+
+{{if .Data.NewToken}}
+{{/* Shown once, and the page says so, because only the hash is
+ stored — coming back for it later is not possible and somebody
+ should learn that now rather than then. */}}
+{{template "callout" dict "Tone" "positive"
+ "Title" "Copy this now — it is not shown again"
+ "Body" (feedURL .Data.Origin .Data.NewToken)}}
+{{end}}
+
+{{/* Both costs, stated before the button rather than in a help page.
+ A subscription link is genuinely useful and genuinely has these
+ two consequences, and somebody deciding should see them. */}}
+{{template "callout" dict "Tone" "warning"
+ "Title" "What a subscription link does"
+ "Body" "Anyone with the link can read this calendar — there is no sign-in on it, because Google and Apple will not sign in. The events they read are then stored in that account, outside this instance. Subscribers also poll constantly (Apple, roughly every fifteen minutes, forever), and each poll wakes this instance."}}
+
+{{if .Data.Feeds}}
+<div rst-list>
+ {{range .Data.Feeds}}
+ <div rst-row>
+ <span rst-row-main>
+ Created {{.CreatedAt.Format "2 January 2006"}}
+ <small rst-row-sub>
+ Polled {{.PollCount}} times{{if .LastPolledAt}}, last {{.LastPolledAt.Format "2 Jan 15:04"}}{{else}} — never yet{{end}}
+ </small>
+ </span>
+ <form method="post" action="/calendars/{{$.Data.Calendar.ID}}/feeds/revoke">
+ <input type="hidden" name="feed_id" value="{{.ID}}">
+ <button rst-btn="ghost compact" class="rst-danger" type="submit">Revoke</button>
+ </form>
+ </div>
+ {{end}}
+</div>
+{{else}}
+{{template "empty-state" dict
+ "Title" "No subscription links"
+ "Body" "A link lets another calendar app read this one. Nothing is shared until you make one."}}
+{{end}}
+
+<form method="post" action="/calendars/{{.Data.Calendar.ID}}/feeds" style="margin-top:var(--rst-sp-4)">
+ <button rst-btn="primary" type="submit">Create a subscription link</button>
+</form>
+{{end}}
diff --git a/internal/calendar/templates/week.html b/internal/calendar/templates/week.html
new file mode 100644
index 0000000..5906e7b
--- /dev/null
+++ b/internal/calendar/templates/week.html
@@ -0,0 +1,60 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "main"}}
+{{/* Full width, not the reading column: a week is seven columns that
+ need the viewport. */}}
+<main id="main" style="padding: var(--rst-sp-4)">
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Title}}</h1>
+ </div>
+ <a rst-btn="primary" href="/events/new">New event</a>
+</header>
+
+<div cal-bar>
+ <a rst-btn href="/week/{{.Data.PrevISO}}">Previous week</a>
+ <a rst-btn href="/week/{{.Data.TodayISO}}">This week</a>
+ <a rst-btn href="/week/{{.Data.NextISO}}">Next week</a>
+ <span cal-bar-spacer></span>
+ <a rst-btn="ghost" href="/day/{{.Data.TodayISO}}">Day</a>
+ <a rst-btn="ghost" href="/agenda">Agenda</a>
+</div>
+
+{{/* The week keeps its two dimensions on a phone and scrolls sideways.
+ Flattening it to a list would make it the agenda, which already
+ exists and is better at being a list. */}}
+<div cal-week-scroll>
+ <div cal-week-grid>
+ <div cal-hours>
+ <div cal-column-head>&nbsp;</div>
+ {{range (index .Data.Days 0).Hours}}<div cal-hour>{{.}}</div>{{end}}
+ </div>
+
+ {{range .Data.Days}}
+ <div>
+ <div cal-column-head="{{if eq .ISO $.Data.TodayISO}}today{{end}}">
+ <a href="/day/{{.ISO}}">{{.Date.Format "Mon 2"}}</a>
+ {{if eq .ISO $.Data.TodayISO}}<span class="rst-sr-only">(today)</span>{{end}}
+ </div>
+ {{if .AllDay}}
+ <div cal-allday>{{range .AllDay}}{{template "chip" .}}{{end}}</div>
+ {{end}}
+ <div cal-column>
+ <div cal-column-rules aria-hidden="true"></div>
+ {{range .Slots}}
+ <a cal-slot href="{{.Href}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%">
+ <span class="rst-sr-only">{{.Label}}</span>
+ </a>
+ {{end}}
+ {{range .Placed}}
+ <div cal-placed style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%">
+ {{template "chip" .Chip}}
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+</div>
+</main>
+{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go
index 51fb612..e6d8a65 100644
--- a/internal/calendar/testing.go
+++ b/internal/calendar/testing.go
@@ -1,6 +1,7 @@
package calendar
import (
+ "bytes"
"database/sql"
"fmt"
"io"
@@ -8,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "os"
"path/filepath"
"strings"
)
@@ -125,6 +127,33 @@ func (a *App) DBForTest() *sql.DB {
return db
}
+// GrepDatabaseForTest searches the raw bytes of this instance's SQLite
+// file, and its write-ahead log, for a string.
+//
+// The bytes, not a query. The one rule is about what the FILE holds,
+// because the platform replicates that file to object storage — so a
+// test that asked SQLite whether a title was readable would be asking
+// the wrong thing entirely, and a column that was never sealed looks
+// identical through Go while being completely different on disk.
+//
+// The WAL is searched too. A recent write may not have reached the main
+// file yet, and a test that checked only the main file would pass by
+// looking somewhere the data has not arrived.
+func (a *App) GrepDatabaseForTest(t TB, needle string) (bool, string) {
+ t.Helper()
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ path := a.cfg.DBPath + suffix
+ b, err := os.ReadFile(path)
+ if err != nil {
+ continue // an absent WAL is not a failure
+ }
+ if bytes.Contains(b, []byte(needle)) {
+ return true, filepath.Base(path)
+ }
+ }
+ return false, ""
+}
+
// LastMailForTest returns the body of the most recent message this app
// sent, or an error outside development where nothing records one.
func (a *App) LastMailForTest() (string, error) {
diff --git a/internal/calendar/viewdata.go b/internal/calendar/viewdata.go
new file mode 100644
index 0000000..4e6ef34
--- /dev/null
+++ b/internal/calendar/viewdata.go
@@ -0,0 +1,244 @@
+package calendar
+
+import (
+ "fmt"
+ "time"
+)
+
+// Chip is one event as a view renders it: already converted into the
+// viewer's zone, already formatted, already told whether it is in the
+// past. Templates do no arithmetic — everything they print is decided
+// here, where it can be tested.
+type Chip struct {
+ Event EventView
+
+ // Href is where the chip goes. An occurrence of a series carries
+ // its own occurrence key, so editing "this one" knows which one.
+ Href string
+
+ Start string // "09:30" in the viewer's zone
+ End string // "10:00"
+
+ // TimeLabel is what the chip prints. Past events are written in the
+ // past tense — "Standup, ended 09:30" — because a calendar that
+ // describes this morning in the future tense is a calendar that
+ // makes you read the clock to know where you are.
+ TimeLabel string
+
+ AllDay bool
+ Past bool
+
+ // AuthoredZone is set only when the event's own zone differs from
+ // the viewer's. The primary time shown is always the viewer's; the
+ // authored one is a whisper beside it, never the headline.
+ AuthoredZone string
+ AuthoredTime string
+
+ Hue int
+ Kind string
+}
+
+// chipFor converts one event into the viewer's zone.
+func chipFor(e EventView, zone string, now int64) Chip {
+ c := Chip{
+ Event: e,
+ Href: eventHref(e),
+ AllDay: e.AllDay,
+ Past: e.OccEndUTC <= now,
+ Hue: e.CalendarHue,
+ Kind: e.Kind,
+ }
+ if e.AllDay {
+ c.TimeLabel = "All day"
+ return c
+ }
+ c.Start = hhmm(e.OccStartUTC, zone)
+ c.End = hhmm(e.OccEndUTC, zone)
+ if c.Past {
+ c.TimeLabel = "ended " + c.End
+ } else {
+ c.TimeLabel = c.Start
+ }
+ // The whisper: only when the event was authored somewhere else.
+ if e.TZ != "" && e.TZ != zone {
+ c.AuthoredZone = e.TZ
+ c.AuthoredTime = hhmm(e.OccStartUTC, e.TZ)
+ }
+ return c
+}
+
+func eventHref(e EventView) string {
+ if e.OccurrenceLocal == "" {
+ return fmt.Sprintf("/events/%d", e.ID)
+ }
+ return fmt.Sprintf("/events/%d/%s", occurrenceOwner(e), compactLocal(e.OccurrenceLocal))
+}
+
+// occurrenceOwner is the series a chip belongs to: the master's id for
+// an expanded occurrence, and for an override the series it overrides —
+// so both forms of the same occurrence share one URL.
+func occurrenceOwner(e EventView) int64 {
+ if e.SeriesID != nil {
+ return *e.SeriesID
+ }
+ return e.ID
+}
+
+// compactLocal turns "2026-09-02T09:00" into "20260902T0900", which is
+// what the occurrence route matches: a URL segment with no colons,
+// because a colon in a path is legal, ugly, and encoded differently by
+// different clients.
+func compactLocal(local string) string {
+ out := make([]byte, 0, len(local))
+ for i := 0; i < len(local); i++ {
+ if local[i] == '-' || local[i] == ':' {
+ continue
+ }
+ out = append(out, local[i])
+ }
+ return string(out)
+}
+
+// expandLocal is compactLocal's inverse.
+func expandCompact(compact string) (string, bool) {
+ // 20260902T0900 → 2026-09-02T09:00; 20260902 → 2026-09-02
+ switch len(compact) {
+ case 13:
+ if compact[8] != 'T' {
+ return "", false
+ }
+ return fmt.Sprintf("%s-%s-%sT%s:%s",
+ compact[0:4], compact[4:6], compact[6:8], compact[9:11], compact[11:13]), true
+ case 8:
+ return fmt.Sprintf("%s-%s-%s", compact[0:4], compact[4:6], compact[6:8]), true
+ default:
+ return "", false
+ }
+}
+
+// DayPage is one day, packed.
+type DayPage struct {
+ Date time.Time
+ ISO string
+ Title string
+ Summary DaySummary
+
+ AllDay []Chip
+ Placed []PlacedChip
+
+ // Slots are the half-hour click-targets that make "create at this
+ // time" work with no JavaScript. Every one is an ordinary link to a
+ // prefilled form.
+ Slots []Slot
+
+ Hours []string
+}
+
+// PlacedChip is a Chip with its position in the day column.
+type PlacedChip struct {
+ Chip
+ TopPct float64
+ HeightPct float64
+ LeftPct float64
+ WidthPct float64
+}
+
+// Slot is one empty half-hour, as a link.
+type Slot struct {
+ Href string
+
+ // Label is for screen readers — "Create an event at 09:30 on
+ // Wednesday 2 September". The visible slot is an empty box, so
+ // without this it would be an unlabelled link.
+ Label string
+
+ TopPct float64
+ HeightPct float64
+}
+
+// slotMinutes is how finely a day can be clicked. Thirty minutes gives
+// 48 links per day column: enough to be useful, few enough that the
+// page stays small and a keyboard user can tab past them.
+const slotMinutes = 30
+
+// BuildDay assembles one day's page data.
+func BuildDay(day time.Time, events []EventView, zone string, now int64, calendarID int64) (DayPage, error) {
+ dayStart, dayEnd, err := DayBounds(day, zone)
+ if err != nil {
+ return DayPage{}, err
+ }
+ loc, err := Zone(zone)
+ if err != nil {
+ return DayPage{}, err
+ }
+ local := day.In(loc)
+
+ p := DayPage{
+ Date: local,
+ ISO: local.Format(LayoutDate),
+ Title: local.Format("Monday 2 January"),
+ Summary: SummariseDay(local, events, dayStart, dayEnd, zone),
+ }
+
+ for _, e := range events {
+ if e.AllDay {
+ p.AllDay = append(p.AllDay, chipFor(e, zone, now))
+ }
+ }
+ for _, pl := range PackDay(events, dayStart, dayEnd) {
+ p.Placed = append(p.Placed, PlacedChip{
+ Chip: chipFor(pl.Event, zone, now),
+ TopPct: pl.TopPct,
+ HeightPct: pl.HeightPct,
+ LeftPct: pl.LeftPct,
+ WidthPct: pl.WidthPct,
+ })
+ }
+
+ span := float64(dayEnd - dayStart)
+ for m := 0; m < 24*60; m += slotMinutes {
+ t := time.Date(local.Year(), local.Month(), local.Day(), 0, m, 0, 0, loc)
+ // AddDate-safe: on a DST day some slots do not exist, and a
+ // slot whose instant falls outside the day is not offered.
+ if t.Unix() < dayStart || t.Unix() >= dayEnd {
+ continue
+ }
+ endT := t.Add(slotMinutes * time.Minute)
+ p.Slots = append(p.Slots, Slot{
+ Href: fmt.Sprintf("/events/new?start=%s&end=%s&calendar=%d",
+ t.Format(LayoutLocal), endT.Format(LayoutLocal), calendarID),
+ Label: fmt.Sprintf("Create an event at %s on %s", t.Format("15:04"), local.Format("Monday 2 January")),
+ TopPct: round2(float64(t.Unix()-dayStart) / span * 100),
+ HeightPct: round2(float64(slotMinutes*60) / span * 100),
+ })
+ }
+
+ for h := 0; h < 24; h++ {
+ p.Hours = append(p.Hours, fmt.Sprintf("%02d:00", h))
+ }
+ return p, nil
+}
+
+// WeekStartFor returns the first day of the week containing day, for a
+// person whose week starts on weekStart (1 Monday, 7 Sunday, 6
+// Saturday).
+func WeekStartFor(day time.Time, weekStart int, zone string) (time.Time, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return time.Time{}, err
+ }
+ d := day.In(loc)
+ d = time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
+
+ // time.Weekday is Sunday=0; this app's column uses Sunday=7 so that
+ // an unset zero is distinguishable from a chosen Sunday.
+ cur := int(d.Weekday())
+ if cur == 0 {
+ cur = 7
+ }
+ if weekStart < 1 || weekStart > 7 {
+ weekStart = WeekStartMonday
+ }
+ back := (cur - weekStart + 7) % 7
+ return d.AddDate(0, 0, -back), nil
+}
diff --git a/internal/calendar/viewmonth.go b/internal/calendar/viewmonth.go
new file mode 100644
index 0000000..68e1cb5
--- /dev/null
+++ b/internal/calendar/viewmonth.go
@@ -0,0 +1,249 @@
+package calendar
+
+import (
+ "fmt"
+ "time"
+)
+
+// MonthPage is a month as a table of weeks.
+//
+// A <table>, deliberately, and not a grid of divs. A month IS tabular
+// data — seven weekday columns against week rows — so the table gives a
+// screen reader the row and column headers for free, and gives a
+// scriptless page working semantics that a div soup would have to
+// reconstruct with ARIA.
+type MonthPage struct {
+ Month time.Time
+ ISO string // "2026-09"
+ Title string // "September 2026"
+
+ // Weekdays are the column headers, already rotated to the person's
+ // week start.
+ Weekdays []string
+
+ Weeks []MonthWeek
+
+ PrevISO string
+ NextISO string
+}
+
+type MonthWeek struct {
+ Days []MonthDay
+}
+
+type MonthDay struct {
+ ISO string
+ Day int
+ Href string
+ Chips []Chip
+
+ // More is how many chips did not fit. The link goes to the day,
+ // which is the only place that can show them all — a month cell
+ // that grew to fit its fullest day would make every other row that
+ // tall.
+ More int
+
+ InMonth bool
+ IsToday bool
+
+ // Weekend is a rendering hint only. It never carries meaning alone:
+ // the date is printed in every cell regardless.
+ Weekend bool
+}
+
+// MaxChipsPerMonthCell is how many events a month cell shows before it
+// says "+N more". Three keeps every row the same height at any
+// realistic density, which is what makes a month scannable at all.
+const MaxChipsPerMonthCell = 3
+
+// BuildMonth assembles the month grid.
+//
+// Events are bucketed by the LOCAL day they fall on in the viewer's
+// zone, and a multi-day event appears in each day it covers. It does
+// NOT get a spanning bar across the row: a bar has to be laid out
+// against every other event in the week, it breaks the moment a week
+// boundary splits it, and it is the single largest source of complexity
+// in a month view. Each day says what is happening that day.
+func BuildMonth(month time.Time, weekStart int, events []EventView, zone string, now int64) (MonthPage, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return MonthPage{}, err
+ }
+ first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, loc)
+
+ p := MonthPage{
+ Month: first,
+ ISO: first.Format("2006-01"),
+ Title: first.Format("January 2006"),
+ PrevISO: first.AddDate(0, -1, 0).Format("2006-01"),
+ NextISO: first.AddDate(0, 1, 0).Format("2006-01"),
+ }
+
+ gridStart, err := WeekStartFor(first, weekStart, zone)
+ if err != nil {
+ return MonthPage{}, err
+ }
+ for i := 0; i < 7; i++ {
+ p.Weekdays = append(p.Weekdays, gridStart.AddDate(0, 0, i).Format("Mon"))
+ }
+
+ byDay := bucketByLocalDay(events, zone)
+ todayISO := time.Unix(now, 0).In(loc).Format(LayoutDate)
+
+ // Six rows always. A month needs five or six depending on where it
+ // starts, and a grid that changes height between months makes the
+ // whole page jump when you click "next".
+ for w := 0; w < 6; w++ {
+ var week MonthWeek
+ for d := 0; d < 7; d++ {
+ day := gridStart.AddDate(0, 0, w*7+d)
+ iso := day.Format(LayoutDate)
+ cell := MonthDay{
+ ISO: iso,
+ Day: day.Day(),
+ Href: "/day/" + iso,
+ InMonth: day.Month() == first.Month(),
+ IsToday: iso == todayISO,
+ Weekend: day.Weekday() == time.Saturday || day.Weekday() == time.Sunday,
+ }
+ all := byDay[iso]
+ for i, e := range all {
+ if i >= MaxChipsPerMonthCell {
+ cell.More = len(all) - MaxChipsPerMonthCell
+ break
+ }
+ cell.Chips = append(cell.Chips, chipFor(e, zone, now))
+ }
+ week.Days = append(week.Days, cell)
+ }
+ p.Weeks = append(p.Weeks, week)
+ }
+ return p, nil
+}
+
+// bucketByLocalDay puts each event under every local date it covers.
+//
+// The end is exclusive, so an event ending at midnight belongs to the
+// day before and not to both — which is the arithmetic that otherwise
+// shows a Tuesday meeting on Wednesday as well.
+func bucketByLocalDay(events []EventView, zone string) map[string][]EventView {
+ out := map[string][]EventView{}
+ loc, err := Zone(zone)
+ if err != nil {
+ return out
+ }
+ for _, e := range events {
+ start := time.Unix(e.OccStartUTC, 0).In(loc)
+ end := time.Unix(e.OccEndUTC, 0).In(loc)
+
+ day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
+ for n := 0; n < 400; n++ {
+ iso := day.Format(LayoutDate)
+ out[iso] = append(out[iso], e)
+
+ next := day.AddDate(0, 0, 1)
+ // Exclusive end: an event finishing exactly at midnight
+ // does not reach the following day.
+ if !next.Before(end) {
+ break
+ }
+ day = next
+ }
+ }
+ for k := range out {
+ sortOccurrences(out[k])
+ }
+ return out
+}
+
+// AgendaPage is the next fourteen days as a list — the view that works
+// on any screen, needs no grid, and is what a phone actually wants.
+type AgendaPage struct {
+ FromISO string
+ Days []AgendaDay
+}
+
+type AgendaDay struct {
+ ISO string
+ Title string
+ Href string
+ Chips []Chip
+ Today bool
+}
+
+// AgendaDays is how far the agenda looks ahead.
+const AgendaDays = 14
+
+// BuildAgenda lists the days that have something in them, and says so
+// when none do.
+func BuildAgenda(from time.Time, events []EventView, zone string, now int64) (AgendaPage, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return AgendaPage{}, err
+ }
+ start := time.Date(from.In(loc).Year(), from.In(loc).Month(), from.In(loc).Day(), 0, 0, 0, 0, loc)
+ byDay := bucketByLocalDay(events, zone)
+ todayISO := time.Unix(now, 0).In(loc).Format(LayoutDate)
+
+ p := AgendaPage{FromISO: start.Format(LayoutDate)}
+ for i := 0; i < AgendaDays; i++ {
+ day := start.AddDate(0, 0, i)
+ iso := day.Format(LayoutDate)
+ all := byDay[iso]
+ if len(all) == 0 {
+ continue
+ }
+ d := AgendaDay{
+ ISO: iso,
+ Title: day.Format("Monday 2 January"),
+ Href: "/day/" + iso,
+ Today: iso == todayISO,
+ }
+ for _, e := range all {
+ d.Chips = append(d.Chips, chipFor(e, zone, now))
+ }
+ p.Days = append(p.Days, d)
+ }
+ return p, nil
+}
+
+// MonthBounds is the half-open UTC range a month grid needs, which is
+// six whole weeks and therefore wider than the month itself.
+func MonthBounds(month time.Time, weekStart int, zone string) (int64, int64, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return 0, 0, err
+ }
+ first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, loc)
+ gridStart, err := WeekStartFor(first, weekStart, zone)
+ if err != nil {
+ return 0, 0, err
+ }
+ return gridStart.Unix(), gridStart.AddDate(0, 0, 42).Unix(), nil
+}
+
+// ParseMonth reads "2006-01".
+func ParseMonth(s string, zone string) (time.Time, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return time.Time{}, err
+ }
+ t, err := time.ParseInLocation("2006-01", s, loc)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("calendar: %q is not a month", s)
+ }
+ return t, nil
+}
+
+// ParseDate reads "2006-01-02" in the viewer's zone.
+func ParseDate(s string, zone string) (time.Time, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return time.Time{}, err
+ }
+ t, err := time.ParseInLocation(LayoutDate, s, loc)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("calendar: %q is not a date", s)
+ }
+ return t, nil
+}
diff --git a/internal/calendar/zones.go b/internal/calendar/zones.go
new file mode 100644
index 0000000..1aa26aa
--- /dev/null
+++ b/internal/calendar/zones.go
@@ -0,0 +1,114 @@
+package calendar
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+)
+
+// CommonZones is the settings page's zone list.
+//
+// A short list, not the whole IANA database. Six hundred names in a
+// select is a control nobody can use, and the seam for the rest is a
+// text field beside it — somebody in Pacific/Chatham types it and the
+// server validates it against the real database, so the list being
+// short costs them one field rather than the feature.
+var CommonZones = []string{
+ "UTC",
+ "Europe/Dublin",
+ "Europe/London",
+ "Europe/Lisbon",
+ "Europe/Madrid",
+ "Europe/Paris",
+ "Europe/Berlin",
+ "Europe/Amsterdam",
+ "Europe/Brussels",
+ "Europe/Zurich",
+ "Europe/Rome",
+ "Europe/Stockholm",
+ "Europe/Helsinki",
+ "Europe/Athens",
+ "Europe/Warsaw",
+ "Europe/Kyiv",
+ "Europe/Istanbul",
+ "Europe/Moscow",
+ "America/St_Johns",
+ "America/Halifax",
+ "America/New_York",
+ "America/Toronto",
+ "America/Chicago",
+ "America/Mexico_City",
+ "America/Denver",
+ "America/Phoenix",
+ "America/Los_Angeles",
+ "America/Vancouver",
+ "America/Anchorage",
+ "America/Sao_Paulo",
+ "America/Argentina/Buenos_Aires",
+ "America/Bogota",
+ "America/Lima",
+ "America/Santiago",
+ "Africa/Cairo",
+ "Africa/Lagos",
+ "Africa/Nairobi",
+ "Africa/Johannesburg",
+ "Asia/Jerusalem",
+ "Asia/Dubai",
+ "Asia/Karachi",
+ "Asia/Kolkata",
+ "Asia/Kathmandu",
+ "Asia/Dhaka",
+ "Asia/Bangkok",
+ "Asia/Jakarta",
+ "Asia/Singapore",
+ "Asia/Hong_Kong",
+ "Asia/Shanghai",
+ "Asia/Manila",
+ "Asia/Tokyo",
+ "Asia/Seoul",
+ "Australia/Perth",
+ "Australia/Adelaide",
+ "Australia/Brisbane",
+ "Australia/Sydney",
+ "Pacific/Auckland",
+ "Pacific/Honolulu",
+}
+
+// etagFor is the feed's cache validator: a hash of exactly the bytes
+// being served, so it changes when and only when the file does.
+//
+// Not the calendar's updated_at, which would miss an occurrence rolling
+// out of the window without anything being edited, and not a random
+// value, which would defeat the 304 entirely.
+func etagFor(body string) string {
+ sum := sha256.Sum256([]byte(body))
+ return `"` + hex.EncodeToString(sum[:16]) + `"`
+}
+
+// safeFilename reduces a calendar's name to something a Content-
+// Disposition header and a filesystem will both accept.
+//
+// The name is content — it came out of a sealed column — so it must not
+// be able to inject a header. Anything outside a conservative set
+// becomes a dash rather than being escaped, because an escaped filename
+// that round-trips through three operating systems is not worth the
+// surface.
+func safeFilename(name string) string {
+ var b strings.Builder
+ for _, r := range name {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == ' ' || r == '-' || r == '_':
+ b.WriteRune('-')
+ }
+ }
+ out := strings.Trim(b.String(), "-")
+ if out == "" {
+ return "calendar"
+ }
+ if len(out) > 60 {
+ out = out[:60]
+ }
+ return out
+}
diff --git a/internal/calendartest/auth_test.go b/internal/calendartest/auth_test.go
index 2c3bdbf..3f20268 100644
--- a/internal/calendartest/auth_test.go
+++ b/internal/calendartest/auth_test.go
@@ -34,7 +34,7 @@ func TestSignInMintsASession(t *testing.T) {
if session == nil || session.Value == "" {
t.Fatal("sign-in minted no session cookie")
}
- if rec := getAs(t, app.Handler(), session, "/"); rec.Code != http.StatusOK {
+ if rec := follow(t, app.Handler(), session, "/"); rec.Code != http.StatusOK {
t.Fatalf("GET / signed in: status %d, want 200", rec.Code)
}
}
diff --git a/internal/calendartest/events_test.go b/internal/calendartest/events_test.go
new file mode 100644
index 0000000..5845f6b
--- /dev/null
+++ b/internal/calendartest/events_test.go
@@ -0,0 +1,237 @@
+package calendartest
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// createEvent posts the real form and returns the new event's path.
+func createEvent(t *testing.T, h http.Handler, session *http.Cookie, form url.Values) string {
+ t.Helper()
+ // Read the SELECTED option, not the first one — that is what a
+ // browser submits when nobody touches the dropdown, and it is the
+ // personal calendar rather than the shared team one. An earlier
+ // version of this helper took the first option, silently created
+ // every test event on the team calendar, and made the isolation
+ // test look like an app bug.
+ if form.Get("calendar_id") == "" {
+ page := getAs(t, h, session, "/events/new").Body.String()
+ m := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("no calendar is selected by default on the create form:\n%s", page)
+ }
+ form.Set("calendar_id", m[1])
+ }
+ rec := postAs(t, h, session, "/events", form)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /events: status %d, want 303\n%s", rec.Code, rec.Body.String())
+ }
+ return rec.Header().Get("Location")
+}
+
+// The whole stack, end to end: a form submission becomes a sealed row,
+// and the day page renders it back in the viewer's own zone.
+func TestCreateAnEventAndSeeItOnTheDay(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ loc := createEvent(t, h, session, url.Values{
+ "title": {"Standup"},
+ "start": {"2026-09-02T09:30"},
+ "end": {"2026-09-02T09:45"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"none"},
+ })
+ if !strings.HasPrefix(loc, "/events/") {
+ t.Fatalf("created event redirected to %q", loc)
+ }
+
+ page := getAs(t, h, session, "/day/2026-09-02")
+ if page.Code != http.StatusOK {
+ t.Fatalf("GET the day: status %d", page.Code)
+ }
+ body := page.Body.String()
+ if !strings.Contains(body, "Standup") {
+ t.Errorf("the event is missing from its own day:\n%s", body)
+ }
+ if !strings.Contains(body, "09:30") {
+ t.Errorf("the day page does not show the event's time:\n%s", body)
+ }
+ // The sentence is computed from real events, so it must have
+ // noticed this one.
+ if !strings.Contains(body, "One thing") {
+ t.Errorf("the day's sentence did not count the event:\n%s", body)
+ }
+}
+
+// A title is content, and content is sealed. The proof is the raw
+// SQLite file: nothing readable may be in it.
+//
+// This is the one rule's own test, and it greps the bytes rather than
+// trusting that the sealing code was called — a column that was never
+// sealed looks identical from Go and completely different on disk.
+func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ const secret = "Quarterly board meeting about the acquisition"
+
+ createEvent(t, app.Handler(), session, url.Values{
+ "title": {secret},
+ "location": {"The unmarked room"},
+ "notes": {"Do not tell anybody"},
+ "start": {"2026-09-02T09:30"},
+ "end": {"2026-09-02T10:30"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"none"},
+ })
+
+ // WAL mode keeps recent writes out of the main file, so the
+ // checkpoint is not optional: without it this test passes by
+ // looking at a file the data has not reached yet.
+ if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
+ t.Fatalf("checkpoint: %v", err)
+ }
+
+ for _, needle := range []string{secret, "The unmarked room", "Do not tell anybody"} {
+ if found, where := app.GrepDatabaseForTest(t, needle); found {
+ t.Errorf("%q appears in plaintext in %s", needle, where)
+ }
+ }
+}
+
+// A row that isn't yours doesn't exist — 404, never 403, so no id can
+// be enumerated by watching which ones answer differently.
+func TestAnotherPersonsEventIsNotFound(t *testing.T) {
+ app, mine := signedIn(t, "me@example.com")
+ h := app.Handler()
+
+ loc := createEvent(t, h, mine, url.Values{
+ "title": {"Mine"},
+ "start": {"2026-09-02T09:30"},
+ "end": {"2026-09-02T10:00"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"none"},
+ })
+
+ // A second person on the same instance. They share the team
+ // calendar, so this event must be on a personal one to be hidden —
+ // which is exactly why a new event defaults to the personal
+ // calendar rather than the shared one.
+ theirs := app.SignInForTest(t, "someone-else@example.com")
+ rec := getAs(t, h, theirs, loc)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("GET %s as another person: status %d, want 404", loc, rec.Code)
+ }
+}
+
+// A recurring event appears on every day the rule names, without any
+// of those occurrences existing as rows.
+func TestARecurringEventAppearsOnEveryDayItNames(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ createEvent(t, h, session, url.Values{
+ "title": {"Daily standup"},
+ "start": {"2026-09-02T09:30"},
+ "end": {"2026-09-02T09:45"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"daily"},
+ })
+
+ for _, day := range []string{"2026-09-02", "2026-09-03", "2026-09-10"} {
+ body := getAs(t, h, session, "/day/"+day).Body.String()
+ if !strings.Contains(body, "Daily standup") {
+ t.Errorf("the series is missing from %s", day)
+ }
+ }
+}
+
+// A form that fails validation must give back what was typed. Losing
+// it is the single most common way a form wastes somebody's time.
+func TestARejectedFormKeepsWhatWasTyped(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+
+ rec := postAs(t, app.Handler(), session, "/events", url.Values{
+ "title": {"A meeting that ends before it starts"},
+ "calendar_id": {"1"},
+ "start": {"2026-09-02T10:00"},
+ "end": {"2026-09-02T09:00"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"none"},
+ })
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status %d, want 422", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "A meeting that ends before it starts") {
+ t.Error("the rejected form lost the title that was typed")
+ }
+ if !strings.Contains(body, "It cannot end before it starts.") {
+ t.Errorf("the rejected form does not say what was wrong:\n%s", body)
+ }
+}
+
+// Every page must work with scripts off. The floor is that no page
+// depends on one to be usable, so nothing here may carry an affordance
+// that only a script can reach.
+func TestEveryPageWorksWithoutScript(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+ today := time.Now().Format("2006-01-02")
+
+ for _, target := range []string{
+ "/today", "/day/" + today, "/week/" + today,
+ "/month/" + today[:7], "/agenda", "/calendars",
+ "/events/new", "/settings", "/import",
+ } {
+ rec := follow(t, h, session, target)
+ if rec.Code != http.StatusOK {
+ t.Errorf("GET %s: status %d, want 200", target, rec.Code)
+ continue
+ }
+ body := rec.Body.String()
+ // An onclick is the tell that an affordance was built as a
+ // script hook rather than as a link or a form.
+ if strings.Contains(body, "onclick=") {
+ t.Errorf("GET %s carries an onclick handler; the scriptless floor is the product", target)
+ }
+ }
+}
+
+// The half-hour slots are what make "click a time to create an event"
+// work with no JavaScript. They are ordinary links, and each one is
+// named for a screen reader.
+func TestTheDayOffersScriptlessSlotLinks(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ body := getAs(t, app.Handler(), session, "/day/2026-09-02").Body.String()
+
+ if n := strings.Count(body, "cal-slot"); n < 40 {
+ t.Errorf("the day offers %d slot links, want one per half hour", n)
+ }
+ if !strings.Contains(body, "Create an event at 09:30") {
+ t.Errorf("slot links carry no accessible name:\n%s", body[:min(2000, len(body))])
+ }
+ if !strings.Contains(body, "/events/new?start=2026-09-02T09%3A30") &&
+ !strings.Contains(body, "/events/new?start=2026-09-02T09:30") {
+ t.Error("a slot link does not prefill the form with its own time")
+ }
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+var _ = calendar.MaxOccurrencesPerSeries
diff --git a/internal/calendartest/harness_test.go b/internal/calendartest/harness_test.go
index 1ba5d52..4785daf 100644
--- a/internal/calendartest/harness_test.go
+++ b/internal/calendartest/harness_test.go
@@ -48,6 +48,24 @@ func getAs(t *testing.T, h http.Handler, session *http.Cookie, target string) *h
return rec
}
+// follow is getAs plus one redirect hop.
+//
+// "/" answers 303 to the viewer's own today, because Today and Day are
+// one page rather than two subtly different ones. A test that wants the
+// page rather than the redirect says so by using this.
+func follow(t *testing.T, h http.Handler, session *http.Cookie, target string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := getAs(t, h, session, target)
+ if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
+ return rec
+ }
+ loc := rec.Header().Get("Location")
+ if loc == "" {
+ t.Fatalf("GET %s: %d with no Location", target, rec.Code)
+ }
+ return getAs(t, h, session, loc)
+}
+
func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
diff --git a/internal/calendartest/index_test.go b/internal/calendartest/index_test.go
index 7877eaf..9385699 100644
--- a/internal/calendartest/index_test.go
+++ b/internal/calendartest/index_test.go
@@ -16,12 +16,14 @@ var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`)
func TestIndexRendersForASignedInPerson(t *testing.T) {
app, session := signedIn(t, "someone@example.com")
- rec := getAs(t, app.Handler(), session, "/")
+ rec := follow(t, app.Handler(), session, "/")
if rec.Code != http.StatusOK {
t.Fatalf("GET /: status %d, want 200", rec.Code)
}
- if !strings.Contains(rec.Body.String(), "<h1>Calendar</h1>") {
- t.Errorf("GET /: page is missing its heading:\n%s", rec.Body.String())
+ // The day page opens with its own sentence, which is the one thing
+ // every day has whether or not anything is on it.
+ if !strings.Contains(rec.Body.String(), "cal-sentence") {
+ t.Errorf("GET /: the day page is missing its summary sentence:\n%s", rec.Body.String())
}
}
diff --git a/internal/calendartest/migrate_test.go b/internal/calendartest/migrate_test.go
index 9ca3752..c8a86ab 100644
--- a/internal/calendartest/migrate_test.go
+++ b/internal/calendartest/migrate_test.go
@@ -29,7 +29,7 @@ func TestSchemaAppliesAndHoldsARow(t *testing.T) {
if session == nil {
t.Fatal("sign-in minted no session")
}
- if rec := getAs(t, app.Handler(), session, "/"); rec.Code != 200 {
+ if rec := follow(t, app.Handler(), session, "/"); rec.Code != 200 {
t.Fatalf("GET /: status %d, want 200", rec.Code)
}
diff --git a/internal/calendartest/summary_test.go b/internal/calendartest/summary_test.go
new file mode 100644
index 0000000..d6619fc
--- /dev/null
+++ b/internal/calendartest/summary_test.go
@@ -0,0 +1,115 @@
+package calendartest
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// The sentence at the top of Today is the app's one opinion, so it gets
+// a test rather than a hope. These assert the wording, because the
+// wording IS the feature — a summary that says the right number in the
+// wrong voice is a summary nobody reads twice.
+func TestDaySummaryReadsLikeASentence(t *testing.T) {
+ const tz = "UTC"
+ day := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) // a Wednesday
+ dayStart, dayEnd, err := calendar.DayBounds(day, tz)
+ if err != nil {
+ t.Fatalf("DayBounds: %v", err)
+ }
+
+ at := func(hh, mm int) int64 {
+ return time.Date(2026, 9, 2, hh, mm, 0, 0, time.UTC).Unix()
+ }
+ ev := func(kind string, fromH, fromM, toH, toM int) calendar.EventView {
+ return calendar.EventView{
+ Event: calendar.Event{Kind: kind},
+ OccStartUTC: at(fromH, fromM),
+ OccEndUTC: at(toH, toM),
+ }
+ }
+
+ t.Run("an empty day says so plainly", func(t *testing.T) {
+ got := calendar.SummariseDay(day, nil, dayStart, dayEnd, tz).Sentence
+ if got != "Wednesday. Nothing booked." {
+ t.Errorf("sentence = %q", got)
+ }
+ })
+
+ t.Run("one thing is not pluralised", func(t *testing.T) {
+ got := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindMeeting, 9, 30, 10, 0),
+ }, dayStart, dayEnd, tz).Sentence
+ if !strings.HasPrefix(got, "Wednesday. One thing; the first at 09:30, the last ends at 10:00.") {
+ t.Errorf("sentence = %q", got)
+ }
+ })
+
+ t.Run("the gap is between the first and last, not the whole day", func(t *testing.T) {
+ s := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindMeeting, 9, 30, 10, 0),
+ ev(calendar.KindMeeting, 16, 0, 17, 0),
+ }, dayStart, dayEnd, tz)
+ // 09:30 to 17:00 is 7h30; 1h30 of it is booked; 6h free.
+ if s.UnbookedMin != 360 {
+ t.Errorf("UnbookedMin = %d, want 360", s.UnbookedMin)
+ }
+ if !strings.Contains(s.Sentence, "That leaves six hours unbooked between them.") {
+ t.Errorf("sentence = %q", s.Sentence)
+ }
+ })
+
+ t.Run("overlapping meetings do not consume the day twice", func(t *testing.T) {
+ s := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindMeeting, 9, 0, 11, 0),
+ ev(calendar.KindMeeting, 10, 0, 12, 0),
+ ev(calendar.KindMeeting, 15, 0, 16, 0),
+ }, dayStart, dayEnd, tz)
+ // 09:00-12:00 merged is three hours, plus one at 15:00. The
+ // span is 09:00-16:00, so three hours are free. Counting the
+ // overlap twice would report four.
+ if s.UnbookedMin != 180 {
+ t.Errorf("UnbookedMin = %d, want 180 (the 10:00-11:00 overlap counted once)", s.UnbookedMin)
+ }
+ })
+
+ t.Run("a full day says the alarming thing plainly", func(t *testing.T) {
+ s := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindMeeting, 9, 0, 13, 0),
+ ev(calendar.KindMeeting, 13, 0, 17, 0),
+ }, dayStart, dayEnd, tz)
+ if !strings.Contains(s.Sentence, "There is no unbooked hour between them.") {
+ t.Errorf("sentence = %q", s.Sentence)
+ }
+ })
+
+ // A birthday does not make a Tuesday fuller. Notes are excluded
+ // from the arithmetic entirely.
+ t.Run("notes do not occupy the day", func(t *testing.T) {
+ s := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindNote, 9, 0, 10, 0),
+ }, dayStart, dayEnd, tz)
+ if s.Count != 0 {
+ t.Errorf("Count = %d, want 0: a note is not a thing that occupies time", s.Count)
+ }
+ if s.Sentence != "Wednesday. Nothing booked." {
+ t.Errorf("sentence = %q", s.Sentence)
+ }
+ })
+
+ // The sentence must never contain a digit where a word was meant,
+ // nor a stray fragment — the failure mode of assembling prose in
+ // code is a sentence that is grammatical nonsense.
+ t.Run("hours and minutes read as English", func(t *testing.T) {
+ s := calendar.SummariseDay(day, []calendar.EventView{
+ ev(calendar.KindMeeting, 9, 0, 9, 30),
+ ev(calendar.KindMeeting, 11, 45, 12, 0),
+ }, dayStart, dayEnd, tz)
+ // 09:00-12:00 span, 45 minutes booked, so 2h15 free.
+ if !strings.Contains(s.Sentence, "two hours and 15 minutes unbooked") {
+ t.Errorf("sentence = %q", s.Sentence)
+ }
+ })
+}
diff --git a/internal/calendartest/tick_test.go b/internal/calendartest/tick_test.go
new file mode 100644
index 0000000..5c18522
--- /dev/null
+++ b/internal/calendartest/tick_test.go
@@ -0,0 +1,65 @@
+package calendartest
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// The scheduled-tick route must be mounted OUTSIDE csrf.Protect, and
+// this test is the whole reason to believe it is.
+//
+// csrf.Protect refuses any POST that carries no Sec-Fetch-Site, Origin
+// or Referer. The platform's scheduler is not a browser and sends none
+// of the three, so a tick route mounted inside the protected router
+// answers 403 — and the platform reads 4xx as "do not retry". The
+// result is reminders that never fire, on an app that looks entirely
+// healthy, with nothing in any log to say why.
+//
+// So: a POST with none of the CSRF evidence must NOT be refused by
+// CSRF. It is refused by carlos.Tick instead, which is a different
+// refusal for a different reason — and off-platform, with no
+// $CARLOS_ADMIN_TOKEN in the environment, Tick is false, so 403 here is
+// the correct answer arrived at correctly.
+//
+// The two are told apart by the body: csrf.Protect writes its own, and
+// this handler writes "forbidden".
+func TestTheTickRouteIsNotBehindCSRF(t *testing.T) {
+ h := newApp(t).Handler()
+
+ // Deliberately bare: no Origin, no Referer, no Sec-Fetch-Site —
+ // exactly what the platform's scheduler sends.
+ req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("POST /jobs/reminders: status %d, want 403 from the tick guard", rec.Code)
+ }
+ if got := strings.TrimSpace(rec.Body.String()); got != "forbidden" {
+ t.Errorf("POST /jobs/reminders was refused by %q, which is not the tick guard — "+
+ "it is almost certainly csrf.Protect, which means the route is inside the "+
+ "protected router and ticks will 403 forever", got)
+ }
+}
+
+// The contrast case: an ordinary state-changing route IS behind CSRF,
+// so the test above is proving a difference rather than proving that
+// CSRF is off everywhere.
+func TestOrdinaryPostsAreBehindCSRF(t *testing.T) {
+ h := newApp(t).Handler()
+
+ req := httptest.NewRequest(http.MethodPost, "/signin",
+ strings.NewReader(url.Values{"address": {"someone@example.com"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ // No Origin: a cross-origin POST, which must be refused.
+ req.Header.Set("Sec-Fetch-Site", "cross-site")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code == http.StatusSeeOther {
+ t.Error("a cross-site POST to /signin succeeded; csrf.Protect is not covering the app")
+ }
+}