oficina / calendar Public

app: calendars, events, and the recurrence expander

The expander is hand-written and iterates in WALL CLOCK, which is the
whole reason it is not a library call. A 09:00 standup is at 09:00 on
both sides of a DST boundary; stepping a UTC instant by 24 hours moves
it by an hour for half the year. A test walks a series across the
October transition and asserts every occurrence is still at 09:00.

Three orderings that calendars get wrong, each with a test:

  COUNT bounds the GENERATED set and EXDATE removes from it afterwards.
  Filter first and a four-occurrence series quietly produces five.

  BYMONTHDAY=31 SKIPS a month with no 31st. It does not clamp to the
  30th — "the 31st" of February is no occurrence at all, and clamping
  is what puts a month-end reminder on the wrong day eight times a year.
  Same rule for 29 February yearly and the fifth Friday of a month.

  Overrides are keyed by the occurrence's ORIGINAL time and rendered at
  their new one. The range query is two passes because of it: expansion
  substitutes overrides it generates, then a second pass adds overrides
  dragged in from an original outside the window. Skip pass two and a
  moved occurrence vanishes; skip the dedupe and it appears twice.

A rule outside the subset is stored verbatim and flagged, never
refused. An importer that eats a rule it does not understand is an
importer that eats data.

ErrNotFound covers both "no such row" and "no grant", so no id can be
enumerated by watching which ones answer differently. Updates go
through an explicit column allowlist — the fields absent from it,
calendar_id and created_by among them, are exactly the ones a hidden
form field must not be able to move.
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 b6e2e40d826048db41f6fcb11bc662b96f9cd46d parent 9efaeb3
8 files changed, +1608 −1
  • internal/calendar/occurrences.go +269 −0
  • internal/calendar/person.go +1 −1
  • internal/calendar/recur.go +262 −0
  • internal/calendar/store_calendar.go +213 −0
  • internal/calendar/store_event.go +324 −0
  • internal/calendar/store_range.go +215 −0
  • internal/calendar/validate.go +118 −0
  • internal/calendartest/recur_test.go +206 −0
diff --git a/internal/calendar/occurrences.go b/internal/calendar/occurrences.go
new file mode 100644
index 0000000..f24fa07
--- /dev/null
+++ b/internal/calendar/occurrences.go
@@ -0,0 +1,269 @@
+package calendar
+
+import (
+ "strings"
+ "time"
+)
+
+// MaxOccurrencesPerSeries caps what one master may contribute to one
+// request. A forever-daily series asked for a decade would otherwise
+// expand three and a half thousand occurrences into a month view
+// nobody can read.
+//
+// A cap rather than a refusal: the window is what bounds a normal
+// request, and this only catches the pathological one.
+const MaxOccurrencesPerSeries = 1000
+
+// Occurrence is one instance of a series, in the series' own zone.
+type Occurrence struct {
+ // Local is the occurrence's ORIGINAL local start — the key an
+ // override is written against, and never its moved time.
+ Local string
+
+ StartUTC int64
+ EndUTC int64
+}
+
+// Expand yields a series' occurrences overlapping the half-open UTC
+// window [from, to).
+//
+// It iterates in WALL CLOCK, not in UTC, and that is the whole reason
+// this is hand-written. A 09:00 daily standup is at 09:00 on both sides
+// of a DST boundary; stepping the UTC instant by 24 hours would move it
+// to 08:00 or 10:00 for half the year. Each occurrence's date is
+// computed in the local calendar and only then converted.
+//
+// The rules that calendars get wrong, and which this follows:
+//
+// - COUNT counts GENERATED occurrences, including ones EXDATE later
+// removes. Filtering first and counting second gives a series more
+// occurrences than it was asked for.
+// - A monthly BYMONTHDAY=31 SKIPS months with no 31st. It does not
+// clamp to the 30th or the 28th — "the 31st" in a month without one
+// is no occurrence at all.
+// - Likewise a yearly rule on 29 February happens in leap years only,
+// and the fifth Friday of a month happens only in months that have
+// one.
+func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string, from, to int64) []Occurrence {
+ loc, err := Zone(tz)
+ if err != nil {
+ return nil
+ }
+ start, _, err := ParseLocal(startLocal, loc)
+ if err != nil {
+ return nil
+ }
+ end, _, err := ParseLocal(endLocal, loc)
+ if err != nil {
+ // An event with an unreadable end still has a start; treat it
+ // as instantaneous rather than dropping it entirely.
+ end = start
+ }
+ // The span in whole days plus the end's time of day, so both ends
+ // move together in wall clock and a DST boundary shifts neither.
+ dayspan := daysBetween(start, end)
+
+ skip := exdateSet(exdates)
+ layout := LayoutLocal
+ if allDay {
+ layout = LayoutDate
+ }
+
+ var out []Occurrence
+ generated := 0
+ for cur, i := start, 0; i < 100000; i++ {
+ if r.Count > 0 && generated >= r.Count {
+ break
+ }
+ if r.HasUntil && cur.After(untilLocal(r, loc)) {
+ break
+ }
+ occStart := cur
+ occEnd := shiftDays(cur, dayspan, end)
+
+ su := occStart.Unix()
+ eu := occEnd.Unix()
+ // Generated counts before EXDATE removes, per the RFC.
+ generated++
+
+ key := occStart.Format(layout)
+ if !skip[key] {
+ // Half-open overlap on both sides: an event that ends
+ // exactly at the window's start is not in the window.
+ if eu > from && su < to {
+ out = append(out, Occurrence{Local: key, StartUTC: su, EndUTC: eu})
+ if len(out) >= MaxOccurrencesPerSeries {
+ break
+ }
+ }
+ }
+ // Past the window with nothing left that could come back.
+ if su >= to {
+ break
+ }
+
+ next, ok := advance(cur, r, loc)
+ if !ok {
+ break
+ }
+ cur = next
+ }
+ return out
+}
+
+// advance steps to the next candidate start in wall clock, skipping
+// dates a rule names but a month does not have.
+func advance(cur time.Time, r Rule, loc *time.Location) (time.Time, bool) {
+ switch r.Freq {
+ case FreqDaily:
+ return cur.AddDate(0, 0, r.Interval), true
+
+ case FreqWeekly:
+ if len(r.ByDay) == 0 {
+ return cur.AddDate(0, 0, 7*r.Interval), true
+ }
+ // Walk forward a day at a time to the next named weekday,
+ // jumping a whole interval of weeks when the week runs out.
+ // Bounded: a week has seven days.
+ for step := 1; step <= 7; step++ {
+ cand := cur.AddDate(0, 0, step)
+ if !containsWeekday(r.ByDay, cand.Weekday()) {
+ continue
+ }
+ // Interval counts WEEKS, so an occurrence in a skipped
+ // week is not one.
+ if r.Interval > 1 && weeksBetween(cur, cand)%r.Interval != 0 {
+ continue
+ }
+ return cand, true
+ }
+ return cur.AddDate(0, 0, 7*r.Interval), true
+
+ case FreqMonthly:
+ // Try successive months and skip any that cannot hold the
+ // named date — never clamp. Bounded so a rule that can never
+ // land (there is none in this subset, but a future one might)
+ // terminates instead of spinning.
+ for step := 1; step <= 120; step++ {
+ base := firstOfMonth(cur, loc).AddDate(0, step*r.Interval, 0)
+ if d, ok := monthlyDate(base, r, cur, loc); ok && d.After(cur) {
+ return d, true
+ }
+ }
+ return time.Time{}, false
+
+ case FreqYearly:
+ for step := 1; step <= 40; step++ {
+ cand := time.Date(cur.Year()+step*r.Interval, cur.Month(), cur.Day(),
+ cur.Hour(), cur.Minute(), 0, 0, loc)
+ // 29 February in a non-leap year rolls into 1 March, which
+ // is a different day and therefore not an occurrence.
+ if cand.Day() == cur.Day() && cand.Month() == cur.Month() {
+ return cand, true
+ }
+ }
+ return time.Time{}, false
+ }
+ return time.Time{}, false
+}
+
+// monthlyDate resolves a monthly rule inside the month base sits in.
+func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (time.Time, bool) {
+ y, m := base.Year(), base.Month()
+ hh, mm := proto.Hour(), proto.Minute()
+
+ switch {
+ case r.Ordinal != 0:
+ return nthWeekday(y, m, r.Ordinal, r.OrdinalDay, hh, mm, loc)
+ case r.MonthDay > 0:
+ if r.MonthDay > daysInMonth(y, m) {
+ return time.Time{}, false // no 31st this month: skip it
+ }
+ return time.Date(y, m, r.MonthDay, hh, mm, 0, 0, loc), true
+ default:
+ // No BY part: the same day-of-month as DTSTART.
+ if proto.Day() > daysInMonth(y, m) {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, proto.Day(), hh, mm, 0, 0, loc), true
+ }
+}
+
+// nthWeekday finds "the 2nd Friday" (n>0) or "the last Friday" (n<0) of
+// a month, reporting false when the month has no such day — a fifth
+// Friday exists in some months and not others.
+func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *time.Location) (time.Time, bool) {
+ if n > 0 {
+ first := time.Date(y, m, 1, hh, mm, 0, 0, loc)
+ delta := (int(wd) - int(first.Weekday()) + 7) % 7
+ day := 1 + delta + (n-1)*7
+ if day > daysInMonth(y, m) {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, day, hh, mm, 0, 0, loc), true
+ }
+ last := time.Date(y, m, daysInMonth(y, m), hh, mm, 0, 0, loc)
+ delta := (int(last.Weekday()) - int(wd) + 7) % 7
+ day := last.Day() - delta + (n+1)*7
+ if day < 1 {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, day, hh, mm, 0, 0, loc), true
+}
+
+func daysInMonth(y int, m time.Month) int {
+ // Day 0 of the next month is the last day of this one.
+ return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}
+
+func firstOfMonth(t time.Time, loc *time.Location) time.Time {
+ return time.Date(t.Year(), t.Month(), 1, t.Hour(), t.Minute(), 0, 0, loc)
+}
+
+func containsWeekday(days []time.Weekday, wd time.Weekday) bool {
+ for _, d := range days {
+ if d == wd {
+ return true
+ }
+ }
+ return false
+}
+
+// weeksBetween counts calendar weeks between two days, for INTERVAL on
+// a weekly rule.
+func weeksBetween(a, b time.Time) int {
+ da := a.AddDate(0, 0, -int(a.Weekday()))
+ db := b.AddDate(0, 0, -int(b.Weekday()))
+ return int(db.Sub(da).Hours() / (24 * 7))
+}
+
+// daysBetween is the whole-day span from a start to an end, used to
+// carry an event's length across occurrences in wall clock.
+func daysBetween(start, end time.Time) int {
+ s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
+ e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
+ return int(e.Sub(s).Hours() / 24)
+}
+
+// shiftDays puts proto's time of day onto a date dayspan days after cur.
+func shiftDays(cur time.Time, dayspan int, proto time.Time) time.Time {
+ d := cur.AddDate(0, 0, dayspan)
+ return time.Date(d.Year(), d.Month(), d.Day(), proto.Hour(), proto.Minute(), 0, 0, cur.Location())
+}
+
+func untilLocal(r Rule, loc *time.Location) time.Time {
+ if r.UntilIsUTC {
+ return r.Until.In(loc)
+ }
+ return time.Date(r.Until.Year(), r.Until.Month(), r.Until.Day(), 23, 59, 59, 0, loc)
+}
+
+func exdateSet(exdates string) map[string]bool {
+ set := map[string]bool{}
+ for _, d := range strings.Split(exdates, ",") {
+ if d = strings.TrimSpace(d); d != "" {
+ set[d] = true
+ }
+ }
+ return set
+}
diff --git a/internal/calendar/person.go b/internal/calendar/person.go
index 4f58e80..8e0b459 100644
--- a/internal/calendar/person.go
+++ b/internal/calendar/person.go
@@ -35,7 +35,7 @@ func (a *App) requirePerson(next http.Handler) http.Handler {
ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- p, err := a.store.EnsurePerson(r.Context(), id.Address)
+ p, err := a.store.EnsurePersonSetup(r.Context(), id.Address)
if err != nil {
// No address in the log line: the error is about this
// request, and `carlos logs` is platform-held storage we
diff --git a/internal/calendar/recur.go b/internal/calendar/recur.go
new file mode 100644
index 0000000..e3ed5fd
--- /dev/null
+++ b/internal/calendar/recur.go
@@ -0,0 +1,262 @@
+package calendar
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// The recurrence subset this app can expand.
+//
+// It is deliberately exactly what a repeat picker offers — daily,
+// weekly on chosen weekdays, monthly on a date or an ordinal weekday,
+// yearly — with an interval and an optional end. That is not a
+// compromise reached by running out of time; it is the set a person can
+// actually express in a form, and RRULE's remainder (BYSETPOS chains,
+// BYYEARDAY, BYWEEKNO, multi-ordinal BYDAY) exists to describe rules
+// that no UI asks for.
+//
+// A rule OUTSIDE the subset is never refused and never dropped. It is
+// stored verbatim, flagged Foreign, shown on the event's page as the
+// raw rule, and its occurrences are not expanded. An importer that ate
+// a rule it did not understand would be an importer that ate data.
+const (
+ FreqDaily = "DAILY"
+ FreqWeekly = "WEEKLY"
+ FreqMonthly = "MONTHLY"
+ FreqYearly = "YEARLY"
+)
+
+// Rule is a parsed RRULE, restricted to the subset above.
+type Rule struct {
+ Freq string
+ Interval int
+
+ // ByDay, for a weekly rule: which weekdays it lands on. Empty means
+ // "the weekday DTSTART falls on", which is the RFC's default.
+ ByDay []time.Weekday
+
+ // For a monthly rule, exactly one of these two shapes:
+ //
+ // BYMONTHDAY=15 → MonthDay = 15
+ // BYDAY=2FR → Ordinal = 2, OrdinalDay = Friday
+ // BYDAY=-1FR → Ordinal = -1, OrdinalDay = Friday
+ MonthDay int
+ Ordinal int
+ OrdinalDay time.Weekday
+
+ // Count and Until are the two ways a series ends; a series may have
+ // neither, and then it is forever.
+ Count int
+ Until time.Time
+ HasUntil bool
+
+ // UntilIsUTC records whether the UNTIL arrived as a UTC instant
+ // (trailing Z) or as a local date. Round-tripping the wrong one
+ // shifts a series' last day by the zone offset.
+ UntilIsUTC bool
+}
+
+// ParseRule reads an RRULE. A rule outside the subset is reported with
+// ErrForeignRule, which callers store rather than reject.
+func ParseRule(s string) (Rule, error) {
+ r := Rule{Interval: 1}
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return r, nil
+ }
+ // Some generators include the property name; the RFC's value is
+ // what is stored, so accept both and keep the value.
+ s = strings.TrimPrefix(s, "RRULE:")
+
+ for _, part := range strings.Split(s, ";") {
+ if part == "" {
+ continue
+ }
+ k, v, ok := strings.Cut(part, "=")
+ if !ok {
+ return r, fmt.Errorf("%w: %q is not KEY=VALUE", ErrForeignRule, part)
+ }
+ k = strings.ToUpper(strings.TrimSpace(k))
+ v = strings.TrimSpace(v)
+ switch k {
+ case "FREQ":
+ switch strings.ToUpper(v) {
+ case FreqDaily, FreqWeekly, FreqMonthly, FreqYearly:
+ r.Freq = strings.ToUpper(v)
+ default:
+ return r, fmt.Errorf("%w: FREQ=%s", ErrForeignRule, v)
+ }
+ case "INTERVAL":
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 1 {
+ return r, fmt.Errorf("%w: INTERVAL=%s", ErrForeignRule, v)
+ }
+ r.Interval = n
+ case "COUNT":
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 1 {
+ return r, fmt.Errorf("%w: COUNT=%s", ErrForeignRule, v)
+ }
+ r.Count = n
+ case "UNTIL":
+ t, isUTC, err := parseUntil(v)
+ if err != nil {
+ return r, err
+ }
+ r.Until, r.HasUntil, r.UntilIsUTC = t, true, isUTC
+ case "BYDAY":
+ if err := parseByDay(&r, v); err != nil {
+ return r, err
+ }
+ case "BYMONTHDAY":
+ n, err := strconv.Atoi(v)
+ // Negative month days (-1 = last day) are a real RFC shape
+ // this subset does not carry, so they are foreign rather
+ // than an error.
+ if err != nil || n < 1 || n > 31 {
+ return r, fmt.Errorf("%w: BYMONTHDAY=%s", ErrForeignRule, v)
+ }
+ r.MonthDay = n
+ case "WKST":
+ // Affects only rules this subset does not express (weekly
+ // with INTERVAL > 1 spanning a week boundary is the real
+ // case, and it is rare enough to accept the default).
+ default:
+ return r, fmt.Errorf("%w: %s", ErrForeignRule, k)
+ }
+ }
+ if r.Freq == "" {
+ return r, fmt.Errorf("%w: no FREQ", ErrForeignRule)
+ }
+ if r.Count > 0 && r.HasUntil {
+ return r, fmt.Errorf("%w: COUNT and UNTIL together", ErrForeignRule)
+ }
+ // Monthly takes one shape or the other, never both.
+ if r.MonthDay > 0 && r.Ordinal != 0 {
+ return r, fmt.Errorf("%w: BYMONTHDAY and an ordinal BYDAY together", ErrForeignRule)
+ }
+ return r, nil
+}
+
+// ErrForeignRule marks a rule outside the subset. It is a signal, not a
+// failure: the caller stores the rule verbatim and stops expanding it.
+var ErrForeignRule = fmt.Errorf("calendar: recurrence outside the supported subset")
+
+// IsForeignRule reports whether err says "outside the subset".
+func IsForeignRule(err error) bool {
+ return err != nil && strings.Contains(err.Error(), ErrForeignRule.Error())
+}
+
+func parseByDay(r *Rule, v string) error {
+ parts := strings.Split(v, ",")
+ // An ordinal prefix ("2FR", "-1SU") is the monthly shape and only
+ // ever appears alone here: "the second Friday and the last Tuesday"
+ // is a real rule and not one this subset carries.
+ if len(parts) == 1 {
+ if ord, wd, ok := parseOrdinalDay(parts[0]); ok {
+ r.Ordinal, r.OrdinalDay = ord, wd
+ return nil
+ }
+ }
+ for _, p := range parts {
+ if _, _, ok := parseOrdinalDay(p); ok {
+ return fmt.Errorf("%w: BYDAY=%s has more than one ordinal", ErrForeignRule, v)
+ }
+ wd, ok := weekdayFromICS(p)
+ if !ok {
+ return fmt.Errorf("%w: BYDAY=%s", ErrForeignRule, v)
+ }
+ r.ByDay = append(r.ByDay, wd)
+ }
+ return nil
+}
+
+// parseOrdinalDay reads "2FR" or "-1SU". A bare "FR" is not an ordinal.
+func parseOrdinalDay(s string) (int, time.Weekday, bool) {
+ s = strings.TrimSpace(strings.ToUpper(s))
+ if len(s) < 3 {
+ return 0, 0, false
+ }
+ num, day := s[:len(s)-2], s[len(s)-2:]
+ if num == "" {
+ return 0, 0, false
+ }
+ n, err := strconv.Atoi(num)
+ if err != nil || n == 0 {
+ return 0, 0, false
+ }
+ wd, ok := weekdayFromICS(day)
+ if !ok {
+ return 0, 0, false
+ }
+ return n, wd, true
+}
+
+var icsWeekdays = map[string]time.Weekday{
+ "SU": time.Sunday, "MO": time.Monday, "TU": time.Tuesday,
+ "WE": time.Wednesday, "TH": time.Thursday, "FR": time.Friday,
+ "SA": time.Saturday,
+}
+
+func weekdayFromICS(s string) (time.Weekday, bool) {
+ wd, ok := icsWeekdays[strings.ToUpper(strings.TrimSpace(s))]
+ return wd, ok
+}
+
+// ICSWeekday is the two-letter form, for export.
+func ICSWeekday(wd time.Weekday) string {
+ for k, v := range icsWeekdays {
+ if v == wd {
+ return k
+ }
+ }
+ return ""
+}
+
+// parseUntil reads both forms the RFC allows: a UTC instant
+// ("20261231T235959Z") and a bare date ("20261231").
+//
+// Which one it was matters on the way back out. Re-emitting a local
+// date as a UTC instant, or the reverse, moves a series' last day by
+// the zone's offset — which is invisible until somebody in UTC+13
+// notices their weekly meeting stopped a day early.
+func parseUntil(v string) (time.Time, bool, error) {
+ v = strings.TrimSpace(v)
+ if t, err := time.Parse("20060102T150405Z", v); err == nil {
+ return t, true, nil
+ }
+ if t, err := time.Parse("20060102", v); err == nil {
+ return t, false, nil
+ }
+ return time.Time{}, false, fmt.Errorf("%w: UNTIL=%s", ErrForeignRule, v)
+}
+
+// RuleUntilUTC is the indexed "this series cannot produce anything
+// after here" bound, so a range query can skip masters that ended
+// before the window without expanding them.
+//
+// It reports ok=false for a rule with no UNTIL — including one bounded
+// by COUNT, whose end cannot be known without expanding it. A missing
+// bound costs an expansion; a wrong one loses events.
+func RuleUntilUTC(rrule, startLocal, tz string, allDay bool) (int64, bool) {
+ if strings.TrimSpace(rrule) == "" {
+ return 0, false
+ }
+ r, err := ParseRule(rrule)
+ if err != nil || !r.HasUntil {
+ return 0, false
+ }
+ if r.UntilIsUTC {
+ return r.Until.Unix(), true
+ }
+ // A bare-date UNTIL is local to the event's own zone, and it is
+ // inclusive of that whole day.
+ loc, err := Zone(tz)
+ if err != nil {
+ return 0, false
+ }
+ end := time.Date(r.Until.Year(), r.Until.Month(), r.Until.Day(), 23, 59, 59, 0, loc)
+ return end.Unix(), true
+}
diff --git a/internal/calendar/store_calendar.go b/internal/calendar/store_calendar.go
new file mode 100644
index 0000000..5597136
--- /dev/null
+++ b/internal/calendar/store_calendar.go
@@ -0,0 +1,213 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// ErrNotFound is what every scoped read returns when the row does not
+// exist OR the viewer has no grant on it. One error for both, on
+// purpose: a handler that turned "exists but not yours" into 403 would
+// let anyone enumerate the team's calendars by watching which ids
+// answered differently. A row that isn't yours doesn't exist.
+var ErrNotFound = errors.New("calendar: not found")
+
+// ErrReadOnly is a viewer attempting a write. It is separate from
+// ErrNotFound because the viewer CAN see the thing — they are looking
+// at its page — so hiding it would be a lie rather than a defence.
+var ErrReadOnly = errors.New("calendar: read only")
+
+// CalendarView is a Calendar with its sealed columns opened. Handlers
+// and templates only ever see this; the sealed struct never leaves the
+// store.
+type CalendarView struct {
+ Calendar
+ Name string
+
+ // Role is the viewer's grant on it, so a template can hide an edit
+ // control the server would refuse anyway.
+ Role string
+}
+
+// TeamCalendarName is what the one shared calendar is called before
+// anybody renames it.
+const TeamCalendarName = "Team"
+
+// EnsurePersonSetup makes a newly-signed-in person usable: their row,
+// the instance's one team calendar, a grant onto it, and a personal
+// calendar of their own.
+//
+// Done here rather than at boot because a boot-time write is a write
+// every instance performs whether or not anybody ever signs in, and
+// because the team calendar's first member is the fact that creates it.
+func (s *Store) EnsurePersonSetup(ctx context.Context, address string) (Person, error) {
+ p, err := s.EnsurePerson(ctx, address)
+ if err != nil {
+ return Person{}, err
+ }
+ team, err := s.ensureTeamCalendar(ctx, p.ID)
+ if err != nil {
+ return Person{}, err
+ }
+ // Editor, not owner: the team calendar belongs to the team, and
+ // nobody should be able to delete it out from under the others.
+ if err := s.ensureGrant(ctx, team.ID, p.ID, RoleEditor, p.ID); err != nil {
+ return Person{}, err
+ }
+ if err := s.ensurePersonalCalendar(ctx, p); err != nil {
+ return Person{}, err
+ }
+ return p, nil
+}
+
+func (s *Store) ensureTeamCalendar(ctx context.Context, creator int64) (Calendar, error) {
+ var c Calendar
+ err := s.g.WithContext(ctx).Where("kind = ?", "team").First(&c).Error
+ if err == nil {
+ return c, nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return Calendar{}, fmt.Errorf("calendar: find team calendar: %w", err)
+ }
+ name, err := s.seal.SealString(seal.PurposeCalendarName, TeamCalendarName)
+ if err != nil {
+ return Calendar{}, fmt.Errorf("calendar: seal team calendar name: %w", err)
+ }
+ c = Calendar{
+ NameSealed: name,
+ Kind: "team",
+ OwnerID: creator,
+ Hue: teamHue,
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.g.WithContext(ctx).Create(&c).Error; err != nil {
+ // A concurrent first sign-in may have won the race. Re-read
+ // rather than fail: either row is the team calendar.
+ var again Calendar
+ if e := s.g.WithContext(ctx).Where("kind = ?", "team").First(&again).Error; e == nil {
+ return again, nil
+ }
+ return Calendar{}, fmt.Errorf("calendar: create team calendar: %w", err)
+ }
+ return c, nil
+}
+
+func (s *Store) ensurePersonalCalendar(ctx context.Context, p Person) error {
+ var n int64
+ if err := s.g.WithContext(ctx).Model(&Calendar{}).
+ Where("kind = ? AND owner_id = ?", "personal", p.ID).Count(&n).Error; err != nil {
+ return fmt.Errorf("calendar: count personal calendars: %w", err)
+ }
+ if n > 0 {
+ return nil
+ }
+ name, err := s.seal.SealString(seal.PurposeCalendarName, "My calendar")
+ if err != nil {
+ return fmt.Errorf("calendar: seal personal calendar name: %w", err)
+ }
+ c := Calendar{
+ NameSealed: name,
+ Kind: "personal",
+ OwnerID: p.ID,
+ Hue: personalHue(p.ID),
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.g.WithContext(ctx).Create(&c).Error; err != nil {
+ return fmt.Errorf("calendar: create personal calendar: %w", err)
+ }
+ return s.ensureGrant(ctx, c.ID, p.ID, RoleOwner, p.ID)
+}
+
+func (s *Store) ensureGrant(ctx context.Context, calendarID, personID int64, role string, by int64) error {
+ g := Grant{}
+ err := s.g.WithContext(ctx).
+ Where(Grant{CalendarID: calendarID, PersonID: personID}).
+ Attrs(Grant{Role: role, GrantedBy: by, CreatedAt: time.Now().UTC()}).
+ FirstOrCreate(&g).Error
+ if err != nil {
+ return fmt.Errorf("calendar: ensure grant: %w", err)
+ }
+ return nil
+}
+
+// Calendars lists what a person may see, most recently created last so
+// the team calendar — always the first row written — leads.
+func (s *Store) Calendars(ctx context.Context, personID int64) ([]CalendarView, error) {
+ type row struct {
+ Calendar
+ Role string
+ }
+ var rows []row
+ err := s.g.WithContext(ctx).
+ Model(&Calendar{}).
+ Select("calendars.*, grants.role as role").
+ Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID).
+ Where("calendars.archived_at IS NULL").
+ Order("calendars.id").
+ Scan(&rows).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: list calendars: %w", err)
+ }
+ out := make([]CalendarView, 0, len(rows))
+ for _, r := range rows {
+ v, err := s.openCalendar(r.Calendar, r.Role)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+// Calendar reads one, scoped. ErrNotFound covers both "no such id" and
+// "no grant" — see its own doc comment.
+func (s *Store) Calendar(ctx context.Context, personID, calendarID int64) (CalendarView, error) {
+ type row struct {
+ Calendar
+ Role string
+ }
+ var r row
+ err := s.g.WithContext(ctx).
+ Model(&Calendar{}).
+ Select("calendars.*, grants.role as role").
+ Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID).
+ Where("calendars.id = ?", calendarID).
+ Limit(1).
+ Scan(&r).Error
+ if err != nil {
+ return CalendarView{}, fmt.Errorf("calendar: read calendar: %w", err)
+ }
+ if r.Calendar.ID == 0 {
+ return CalendarView{}, ErrNotFound
+ }
+ return s.openCalendar(r.Calendar, r.Role)
+}
+
+func (s *Store) openCalendar(c Calendar, role string) (CalendarView, error) {
+ name, err := s.seal.OpenString(seal.PurposeCalendarName, c.NameSealed)
+ if err != nil {
+ // No name in the error: it is the thing we failed to decrypt,
+ // and an error string is somewhere we cannot control the
+ // storage of.
+ return CalendarView{}, fmt.Errorf("calendar: open calendar name (id %d): %w", c.ID, err)
+ }
+ return CalendarView{Calendar: c, Name: name, Role: role}, 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
+// two people rarely collide, and never predictably.
+const teamHue = 220
+
+func personalHue(personID int64) int {
+ // 137 is close to the golden angle in degrees, which spreads
+ // successive ids about as far apart as a fixed step can.
+ return int((personID*137 + 40) % 360)
+}
diff --git a/internal/calendar/store_event.go b/internal/calendar/store_event.go
new file mode 100644
index 0000000..66aa452
--- /dev/null
+++ b/internal/calendar/store_event.go
@@ -0,0 +1,324 @@
+package calendar
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// EventInput is what a form supplies. A struct, never a bind onto the
+// model: binding a request body onto a GORM struct is how a hidden
+// field ends up setting calendar_id, sequence, or created_by.
+type EventInput struct {
+ CalendarID int64
+ Title string
+ Location string
+ Notes string
+
+ AllDay bool
+ StartLocal string
+ EndLocal string
+ TZ string
+
+ RRule string
+ Kind string
+ Status string
+ Exdates string
+}
+
+// EventView is an Event with its sealed columns opened, plus the one
+// occurrence it is being shown as. Handlers and templates see only
+// this.
+type EventView struct {
+ Event
+ Title string
+ Location string
+ Notes string
+
+ // StartUTC and EndUTC on the embedded Event are the SERIES master's
+ // own instants. For an expanded occurrence these two are the
+ // occurrence's, which is what every view sorts and packs on.
+ OccStartUTC int64
+ OccEndUTC int64
+
+ // OccurrenceLocal is the occurrence's original local start, and the
+ // key an override is written against. Empty for a single event.
+ OccurrenceLocal string
+
+ // CalendarName and CalendarHue travel with the event because every
+ // view shows events from several calendars at once and a second
+ // lookup per chip is a query per row.
+ CalendarName string
+ CalendarHue int
+
+ // Role is the viewer's grant on the owning calendar.
+ Role string
+}
+
+// Writable reports whether the viewer may change this event.
+func (v EventView) Writable() bool { return CanWrite(v.Role) }
+
+// CreateEvent writes a new event onto a calendar the person may write.
+func (s *Store) CreateEvent(ctx context.Context, personID int64, in EventInput) (Event, error) {
+ cal, err := s.Calendar(ctx, personID, in.CalendarID)
+ if err != nil {
+ return Event{}, err
+ }
+ if !CanWrite(cal.Role) {
+ return Event{}, ErrReadOnly
+ }
+ e, err := s.buildEvent(personID, in)
+ if err != nil {
+ return Event{}, err
+ }
+ if err := s.g.WithContext(ctx).Create(&e).Error; err != nil {
+ return Event{}, fmt.Errorf("calendar: create event: %w", err)
+ }
+ return e, nil
+}
+
+// buildEvent turns validated input into a row, sealing what is content
+// and deriving what is an index.
+func (s *Store) buildEvent(personID int64, in EventInput) (Event, error) {
+ if err := ValidateEventInput(&in); err != nil {
+ return Event{}, err
+ }
+ startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return Event{}, err
+ }
+ endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return Event{}, err
+ }
+
+ uid := newUID()
+ uidSealed, err := s.seal.SealString(seal.PurposeEventUID, uid)
+ if err != nil {
+ return Event{}, fmt.Errorf("calendar: seal uid: %w", err)
+ }
+ title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title)
+ if err != nil {
+ return Event{}, fmt.Errorf("calendar: seal title: %w", err)
+ }
+ loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location)
+ if err != nil {
+ return Event{}, fmt.Errorf("calendar: seal location: %w", err)
+ }
+ notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes)
+ if err != nil {
+ return Event{}, fmt.Errorf("calendar: seal notes: %w", err)
+ }
+
+ now := time.Now().UTC()
+ e := Event{
+ CalendarID: in.CalendarID,
+ UIDHash: HashUID(uid),
+ UIDSealed: uidSealed,
+
+ TitleSealed: title,
+ LocationSealed: loc,
+ NotesSealed: notes,
+
+ AllDay: in.AllDay,
+ StartLocal: in.StartLocal,
+ EndLocal: in.EndLocal,
+ TZ: in.TZ,
+ StartUTC: startUTC,
+ EndUTC: endUTC,
+
+ RRule: in.RRule,
+ Exdates: in.Exdates,
+ Kind: in.Kind,
+ Status: in.Status,
+
+ CreatedBy: personID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok {
+ e.UntilUTC = &until
+ }
+ return e, nil
+}
+
+// Event reads one event, scoped through a grant on its calendar.
+func (s *Store) Event(ctx context.Context, personID, eventID int64) (EventView, error) {
+ type row struct {
+ Event
+ Role string
+ CalendarName []byte
+ CalendarHue int
+ }
+ var r row
+ err := s.g.WithContext(ctx).
+ Model(&Event{}).
+ Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue").
+ Joins("JOIN calendars ON calendars.id = events.calendar_id").
+ Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID).
+ Where("events.id = ? AND events.deleted_at IS NULL", eventID).
+ Limit(1).
+ Scan(&r).Error
+ if err != nil {
+ return EventView{}, fmt.Errorf("calendar: read event: %w", err)
+ }
+ if r.Event.ID == 0 {
+ return EventView{}, ErrNotFound
+ }
+ return s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+}
+
+// UpdateEvent replaces the editable fields of an existing event.
+func (s *Store) UpdateEvent(ctx context.Context, personID, eventID int64, in EventInput) error {
+ existing, err := s.Event(ctx, personID, eventID)
+ if err != nil {
+ return err
+ }
+ if !existing.Writable() {
+ return ErrReadOnly
+ }
+ if err := ValidateEventInput(&in); err != nil {
+ return err
+ }
+ startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return err
+ }
+ endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return err
+ }
+ title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title)
+ if err != nil {
+ return fmt.Errorf("calendar: seal title: %w", err)
+ }
+ loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location)
+ if err != nil {
+ return fmt.Errorf("calendar: seal location: %w", err)
+ }
+ notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes)
+ if err != nil {
+ return fmt.Errorf("calendar: seal notes: %w", err)
+ }
+
+ // An explicit column allowlist, never a struct update: the fields
+ // absent from this list — calendar_id, uid, created_by, series_id —
+ // are exactly the ones a hidden form field must not be able to
+ // move.
+ updates := map[string]any{
+ "title_sealed": title,
+ "location_sealed": loc,
+ "notes_sealed": notes,
+ "all_day": in.AllDay,
+ "start_local": in.StartLocal,
+ "end_local": in.EndLocal,
+ "tz": in.TZ,
+ "start_utc": startUTC,
+ "end_utc": endUTC,
+ "rrule": in.RRule,
+ "exdates": in.Exdates,
+ "kind": in.Kind,
+ "status": in.Status,
+ // SEQUENCE is what tells a re-importing client that this is a
+ // newer version of an event it already has.
+ "sequence": existing.Sequence + 1,
+ "updated_at": time.Now().UTC(),
+ }
+ if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok {
+ updates["until_utc"] = until
+ } else {
+ updates["until_utc"] = nil
+ }
+ if err := s.g.WithContext(ctx).Model(&Event{}).
+ Where("id = ?", eventID).Updates(updates).Error; err != nil {
+ return fmt.Errorf("calendar: update event: %w", err)
+ }
+ return nil
+}
+
+// DeleteEvent soft-deletes an event and, when it is a series master,
+// its overrides with it — an override of a series that no longer exists
+// is a row nothing can render and nothing can reach.
+func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error {
+ existing, err := s.Event(ctx, personID, eventID)
+ if err != nil {
+ return err
+ }
+ if !existing.Writable() {
+ return ErrReadOnly
+ }
+ now := time.Now().UTC()
+ return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&Event{}).Where("id = ?", eventID).
+ Update("deleted_at", now).Error; err != nil {
+ return fmt.Errorf("calendar: delete event: %w", err)
+ }
+ if err := tx.Model(&Event{}).Where("series_id = ?", eventID).
+ Update("deleted_at", now).Error; err != nil {
+ return fmt.Errorf("calendar: delete overrides: %w", err)
+ }
+ return nil
+ })
+}
+
+func (s *Store) openEvent(e Event, role string, calName []byte, hue int) (EventView, error) {
+ title, err := s.seal.OpenString(seal.PurposeEventTitle, e.TitleSealed)
+ if err != nil {
+ return EventView{}, fmt.Errorf("calendar: open title (event %d): %w", e.ID, err)
+ }
+ loc, err := s.seal.OpenString(seal.PurposeEventLocation, e.LocationSealed)
+ if err != nil {
+ return EventView{}, fmt.Errorf("calendar: open location (event %d): %w", e.ID, err)
+ }
+ notes, err := s.seal.OpenString(seal.PurposeEventNotes, e.NotesSealed)
+ if err != nil {
+ return EventView{}, fmt.Errorf("calendar: open notes (event %d): %w", e.ID, err)
+ }
+ name, err := s.seal.OpenString(seal.PurposeCalendarName, calName)
+ if err != nil {
+ return EventView{}, fmt.Errorf("calendar: open calendar name (event %d): %w", e.ID, err)
+ }
+ return EventView{
+ Event: e,
+ Title: title,
+ Location: loc,
+ Notes: notes,
+ OccStartUTC: e.StartUTC,
+ OccEndUTC: e.EndUTC,
+ CalendarName: name,
+ CalendarHue: hue,
+ Role: role,
+ }, nil
+}
+
+// newUID mints an iCalendar UID. Random rather than derived from the
+// content, because a UID must survive every edit the content makes.
+func newUID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ // crypto/rand does not fail on any platform this runs on, and
+ // a UID that silently repeated would make two events one on
+ // every client that imported them.
+ panic("calendar: no randomness for a UID: " + err.Error())
+ }
+ return hex.EncodeToString(b) + "@calendar.oficina"
+}
+
+// HashUID is the lookup key a re-import dedupes on, since the UID
+// itself is sealed and sealed values are not comparable.
+func HashUID(uid string) string {
+ sum := sha256.Sum256([]byte(uid))
+ return hex.EncodeToString(sum[:])
+}
+
+// NotFound reports whether err is the scoped-read miss, so handlers can
+// answer 404 without importing errors.Is at every call site.
+func NotFound(err error) bool { return errors.Is(err, ErrNotFound) }
diff --git a/internal/calendar/store_range.go b/internal/calendar/store_range.go
new file mode 100644
index 0000000..a94bac8
--- /dev/null
+++ b/internal/calendar/store_range.go
@@ -0,0 +1,215 @@
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "sort"
+)
+
+// eventRow is the join every range query reads: the event plus the two
+// things a chip needs from its calendar and the viewer's grant.
+type eventRow struct {
+ Event
+ Role string
+ CalendarName []byte
+ CalendarHue int
+}
+
+// EventsInRange returns everything a person may see that overlaps the
+// half-open UTC window [from, to), with series expanded and overrides
+// substituted.
+//
+// The two-pass shape is what makes moved occurrences behave. Overrides
+// are stored against the occurrence's ORIGINAL time and may be moved to
+// any other, so:
+//
+// 1. Expand each master over the window and substitute any override
+// matching the occurrence's original key. An override that moved
+// OUT of the window is dropped here, which is correct — it is no
+// longer in these days.
+// 2. Separately collect overrides whose own times land IN the window,
+// and add the ones pass 1 did not already place. That is the
+// occurrence dragged in from last week, which pass 1 cannot see
+// because the master never generates it here.
+//
+// Skipping pass 2 is the classic vanishing-event bug; skipping the
+// dedupe is the classic duplicated one.
+func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]EventView, error) {
+ rows, err := s.rangeRows(ctx, personID, from, to)
+ if err != nil {
+ return nil, err
+ }
+
+ // Overrides for every series touched, keyed by series and original
+ // occurrence. Fetched in one query rather than per occurrence: a
+ // query inside an expansion loop is a query per day per series.
+ var masterIDs []int64
+ for _, r := range rows {
+ if r.RRule != "" && r.SeriesID == nil {
+ masterIDs = append(masterIDs, r.ID)
+ }
+ }
+ overrides, err := s.overridesFor(ctx, personID, masterIDs)
+ if err != nil {
+ return nil, err
+ }
+
+ placed := map[string]bool{}
+ var out []EventView
+
+ for _, r := range rows {
+ switch {
+ case r.SeriesID != nil:
+ // Handled below, in pass 2 — an override is never rendered
+ // as if it were an independent event.
+ continue
+
+ case r.RRule == "":
+ v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+
+ default:
+ // A rule this app cannot expand still has its own first
+ // occurrence, which is a real event on a real day.
+ rule, perr := ParseRule(r.RRule)
+ if perr != nil {
+ v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ if v.EndUTC > from && v.StartUTC < to {
+ out = append(out, v)
+ }
+ continue
+ }
+ for _, occ := range Expand(r.StartLocal, r.EndLocal, r.TZ, r.AllDay, rule, r.Exdates, from, to) {
+ key := overrideKey(r.ID, occ.Local)
+ if ov, ok := overrides[key]; ok {
+ placed[key] = true
+ if ov.DeletedAt != nil {
+ continue
+ }
+ v, err := s.openEvent(ov.Event, ov.Role, ov.CalendarName, ov.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ // An override moved outside the window is not in
+ // these days any more.
+ if v.OccEndUTC <= from || v.OccStartUTC >= to {
+ continue
+ }
+ v.OccurrenceLocal = occ.Local
+ out = append(out, v)
+ continue
+ }
+ v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ v.OccStartUTC, v.OccEndUTC = occ.StartUTC, occ.EndUTC
+ v.OccurrenceLocal = occ.Local
+ out = append(out, v)
+ }
+ }
+ }
+
+ // Pass 2: overrides dragged into the window from an original the
+ // expansion never reached.
+ for key, ov := range overrides {
+ if placed[key] || ov.DeletedAt != nil {
+ continue
+ }
+ if ov.EndUTC <= from || ov.StartUTC >= to {
+ continue
+ }
+ v, err := s.openEvent(ov.Event, ov.Role, ov.CalendarName, ov.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ v.OccurrenceLocal = ov.RecurrenceIDLocal
+ out = append(out, v)
+ }
+
+ sortOccurrences(out)
+ return out, nil
+}
+
+// rangeRows reads the candidate events: anything overlapping the window,
+// plus every series master that could still be producing in it.
+func (s *Store) rangeRows(ctx context.Context, personID, from, to int64) ([]eventRow, error) {
+ var rows []eventRow
+ err := s.g.WithContext(ctx).
+ Model(&Event{}).
+ Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue").
+ Joins("JOIN calendars ON calendars.id = events.calendar_id AND calendars.archived_at IS NULL").
+ Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID).
+ Where("events.deleted_at IS NULL").
+ Where(`
+ -- a single, or an override, overlapping the window
+ (events.rrule = '' AND events.end_utc > ? AND events.start_utc < ?)
+ -- or a master that starts before the window ends and has
+ -- not already finished. until_utc is NULL for a forever
+ -- series and for one bounded by COUNT, whose end cannot be
+ -- known without expanding it — so those are always
+ -- candidates, and the expander bounds them.
+ OR (events.rrule <> '' AND events.start_utc < ? AND (events.until_utc IS NULL OR events.until_utc >= ?))
+ `, from, to, to, from).
+ Scan(&rows).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: range query: %w", err)
+ }
+ return rows, nil
+}
+
+// overridesFor reads every override of the given series, keyed by
+// series and ORIGINAL occurrence.
+func (s *Store) overridesFor(ctx context.Context, personID int64, seriesIDs []int64) (map[string]eventRow, error) {
+ out := map[string]eventRow{}
+ if len(seriesIDs) == 0 {
+ return out, nil
+ }
+ var rows []eventRow
+ err := s.g.WithContext(ctx).
+ Model(&Event{}).
+ Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue").
+ Joins("JOIN calendars ON calendars.id = events.calendar_id").
+ Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID).
+ Where("events.series_id IN ?", seriesIDs).
+ Scan(&rows).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: overrides query: %w", err)
+ }
+ for _, r := range rows {
+ out[overrideKey(*r.SeriesID, r.RecurrenceIDLocal)] = r
+ }
+ return out, nil
+}
+
+func overrideKey(seriesID int64, local string) string {
+ return fmt.Sprintf("%d/%s", seriesID, local)
+}
+
+// sortOccurrences puts a day in reading order: earliest first, all-day
+// events ahead of timed ones that start at the same moment, then by
+// title so the order is stable across requests.
+//
+// Stability matters more than it looks: an unstable sort makes a page
+// reorder itself between two identical requests, which reads as data
+// changing when nothing has.
+func sortOccurrences(v []EventView) {
+ sort.SliceStable(v, func(i, j int) bool {
+ if v[i].OccStartUTC != v[j].OccStartUTC {
+ return v[i].OccStartUTC < v[j].OccStartUTC
+ }
+ if v[i].AllDay != v[j].AllDay {
+ return v[i].AllDay
+ }
+ if v[i].Title != v[j].Title {
+ return v[i].Title < v[j].Title
+ }
+ return v[i].ID < v[j].ID
+ })
+}
diff --git a/internal/calendar/validate.go b/internal/calendar/validate.go
new file mode 100644
index 0000000..1d4331d
--- /dev/null
+++ b/internal/calendar/validate.go
@@ -0,0 +1,118 @@
+package calendar
+
+import (
+ "fmt"
+ "strings"
+)
+
+// MaxTitleLen and friends bound what a form may store. Not security —
+// the columns are sealed and SQLite does not care — but a title nobody
+// can read in a chip is a title nobody wanted, and an unbounded notes
+// field is an unbounded row in a file that gets replicated.
+const (
+ MaxTitleLen = 200
+ MaxLocationLen = 200
+ MaxNotesLen = 4000
+)
+
+// ValidationError is a message meant for the person who submitted the
+// form, beside the field it is about. It carries no stored content.
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+func (e ValidationError) Error() string { return e.Field + ": " + e.Message }
+
+// Invalid reports whether err is a ValidationError, so a handler can
+// re-render the form with the message rather than answering 500.
+func Invalid(err error) (ValidationError, bool) {
+ var v ValidationError
+ if err == nil {
+ return v, false
+ }
+ if ve, ok := err.(ValidationError); ok {
+ return ve, true
+ }
+ return v, false
+}
+
+// ValidateEventInput normalises in place and reports the first problem.
+//
+// Normalising here rather than in the handler means the store cannot be
+// handed an event with an unparseable time or a kind nothing renders,
+// whatever calls it — including the ICS importer, which is the caller
+// most likely to supply something strange.
+func ValidateEventInput(in *EventInput) error {
+ in.Title = strings.TrimSpace(in.Title)
+ in.Location = strings.TrimSpace(in.Location)
+ in.Notes = strings.TrimSpace(in.Notes)
+ in.TZ = strings.TrimSpace(in.TZ)
+ in.RRule = strings.TrimSpace(in.RRule)
+
+ if in.Title == "" {
+ return ValidationError{"title", "Give it a name."}
+ }
+ if len(in.Title) > MaxTitleLen {
+ return ValidationError{"title", fmt.Sprintf("Keep it under %d characters.", MaxTitleLen)}
+ }
+ if len(in.Location) > MaxLocationLen {
+ return ValidationError{"location", fmt.Sprintf("Keep it under %d characters.", MaxLocationLen)}
+ }
+ if len(in.Notes) > MaxNotesLen {
+ return ValidationError{"notes", fmt.Sprintf("Keep it under %d characters.", MaxNotesLen)}
+ }
+
+ switch in.Kind {
+ case KindMeeting, KindCommitment, KindNote:
+ case "":
+ in.Kind = KindMeeting
+ default:
+ return ValidationError{"kind", "Pick a meeting, a commitment or a note."}
+ }
+ switch in.Status {
+ case StatusConfirmed, StatusTentative, StatusCancelled:
+ case "":
+ in.Status = StatusConfirmed
+ default:
+ return ValidationError{"status", "Pick a status."}
+ }
+
+ // A timed event needs a zone; an all-day one must not have its zone
+ // consulted at all, and carrying one would invite a later reader to
+ // use it.
+ if in.AllDay {
+ in.TZ = ""
+ } else if in.TZ == "" {
+ return ValidationError{"tz", "Say which time zone this is in."}
+ }
+ if !in.AllDay {
+ if _, err := Zone(in.TZ); err != nil {
+ return ValidationError{"tz", "That is not a time zone we know."}
+ }
+ }
+
+ start, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return ValidationError{"start", "That is not a time we can read."}
+ }
+ if strings.TrimSpace(in.EndLocal) == "" {
+ return ValidationError{"end", "Say when it ends."}
+ }
+ end, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
+ if err != nil {
+ return ValidationError{"end", "That is not a time we can read."}
+ }
+
+ // End is exclusive, so equal is empty and earlier is backwards.
+ // Both are refused rather than silently corrected: an event that
+ // quietly moved its own end is a worse surprise than a rejected
+ // form.
+ if end < start {
+ return ValidationError{"end", "It cannot end before it starts."}
+ }
+ if end == start && in.AllDay {
+ return ValidationError{"end", "An all-day event ends on the following day."}
+ }
+ return nil
+}
diff --git a/internal/calendartest/recur_test.go b/internal/calendartest/recur_test.go
new file mode 100644
index 0000000..ff6adad
--- /dev/null
+++ b/internal/calendartest/recur_test.go
@@ -0,0 +1,206 @@
+package calendartest
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// expandLocal is the shape every case here wants: give it a rule and a
+// window in local dates, get back the occurrence keys.
+func expandLocal(t *testing.T, startLocal, endLocal, tz, rrule, exdates, fromDate, toDate string) []string {
+ t.Helper()
+ r, err := calendar.ParseRule(rrule)
+ if err != nil {
+ t.Fatalf("ParseRule(%q): %v", rrule, err)
+ }
+ from, err := calendar.ToUTC(fromDate, tz, true)
+ if err != nil {
+ t.Fatalf("from: %v", err)
+ }
+ to, err := calendar.ToUTC(toDate, tz, true)
+ if err != nil {
+ t.Fatalf("to: %v", err)
+ }
+ occs := calendar.Expand(startLocal, endLocal, tz, false, r, exdates, from, to)
+ keys := make([]string, 0, len(occs))
+ for _, o := range occs {
+ keys = append(keys, o.Local)
+ }
+ return keys
+}
+
+func TestExpandDailyAndWeekly(t *testing.T) {
+ const tz = "Europe/Dublin"
+
+ t.Run("daily", func(t *testing.T) {
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz,
+ "FREQ=DAILY", "", "2026-09-01", "2026-09-05")
+ want := []string{
+ "2026-09-01T09:00", "2026-09-02T09:00",
+ "2026-09-03T09:00", "2026-09-04T09:00",
+ }
+ assertKeys(t, got, want)
+ })
+
+ t.Run("weekly on chosen days", func(t *testing.T) {
+ // 1 September 2026 is a Tuesday.
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz,
+ "FREQ=WEEKLY;BYDAY=TU,TH", "", "2026-09-01", "2026-09-15")
+ want := []string{
+ "2026-09-01T09:00", "2026-09-03T09:00",
+ "2026-09-08T09:00", "2026-09-10T09:00",
+ }
+ assertKeys(t, got, want)
+ })
+
+ t.Run("interval skips weeks", func(t *testing.T) {
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz,
+ "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", "", "2026-09-01", "2026-10-01")
+ want := []string{"2026-09-01T09:00", "2026-09-15T09:00", "2026-09-29T09:00"}
+ assertKeys(t, got, want)
+ })
+}
+
+// The 31st of a month that has no 31st is NO OCCURRENCE — never the
+// 30th, never the 28th. Clamping is the bug that puts a "last day of
+// the month" payment reminder on the wrong day eight times a year.
+func TestMonthlyOnThe31stSkipsShortMonths(t *testing.T) {
+ got := expandLocal(t, "2026-01-31T09:00", "2026-01-31T09:30", "UTC",
+ "FREQ=MONTHLY;BYMONTHDAY=31", "", "2026-01-01", "2026-08-01")
+ // January, March, May and July have a 31st. February, April and
+ // June do not, and are absent rather than clamped.
+ want := []string{
+ "2026-01-31T09:00", "2026-03-31T09:00",
+ "2026-05-31T09:00", "2026-07-31T09:00",
+ }
+ assertKeys(t, got, want)
+}
+
+// A fifth Friday exists in some months and not others, and "the last
+// Friday" is a different rule from "the fourth Friday".
+func TestMonthlyOrdinalWeekday(t *testing.T) {
+ t.Run("second Friday", func(t *testing.T) {
+ got := expandLocal(t, "2026-09-11T09:00", "2026-09-11T09:30", "UTC",
+ "FREQ=MONTHLY;BYDAY=2FR", "", "2026-09-01", "2026-12-01")
+ want := []string{"2026-09-11T09:00", "2026-10-09T09:00", "2026-11-13T09:00"}
+ assertKeys(t, got, want)
+ })
+
+ t.Run("last Friday is not the fourth", func(t *testing.T) {
+ last := expandLocal(t, "2026-01-30T09:00", "2026-01-30T09:30", "UTC",
+ "FREQ=MONTHLY;BYDAY=-1FR", "", "2026-01-01", "2026-04-01")
+ fourth := expandLocal(t, "2026-01-23T09:00", "2026-01-23T09:30", "UTC",
+ "FREQ=MONTHLY;BYDAY=4FR", "", "2026-01-01", "2026-04-01")
+ if len(last) == 0 || len(fourth) == 0 {
+ t.Fatalf("expected occurrences: last=%v fourth=%v", last, fourth)
+ }
+ // January 2026 has five Fridays, so the two rules disagree in
+ // that month — which is the whole point of having both.
+ if last[0] == fourth[0] {
+ t.Errorf("last Friday and fourth Friday agree in January (%s); one of the rules is wrong", last[0])
+ }
+ })
+}
+
+// 29 February happens in leap years only. Rolling it into 1 March is a
+// different day, and a birthday on the wrong day is the bug.
+func TestYearlyOnLeapDaySkipsNonLeapYears(t *testing.T) {
+ got := expandLocal(t, "2028-02-29T09:00", "2028-02-29T09:30", "UTC",
+ "FREQ=YEARLY", "", "2028-01-01", "2037-01-01")
+ want := []string{"2028-02-29T09:00", "2032-02-29T09:00", "2036-02-29T09:00"}
+ assertKeys(t, got, want)
+}
+
+// COUNT bounds the GENERATED set, and EXDATE removes from it
+// afterwards. Filtering first and counting second hands a series more
+// occurrences than it was asked for — here, five instead of four.
+func TestCountIsAppliedBeforeExdate(t *testing.T) {
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC",
+ "FREQ=DAILY;COUNT=4", "2026-09-02T09:00", "2026-09-01", "2026-10-01")
+ want := []string{"2026-09-01T09:00", "2026-09-03T09:00", "2026-09-04T09:00"}
+ assertKeys(t, got, want)
+ if len(got) == 4 {
+ t.Error("EXDATE was filtered before COUNT was counted: the series produced a fifth day to replace the excluded one")
+ }
+}
+
+// A daily 09:00 standup is at 09:00 on both sides of a DST boundary.
+// Stepping the UTC instant by 24 hours instead would move it by an
+// hour for half the year.
+func TestWallClockSurvivesDST(t *testing.T) {
+ const tz = "Europe/Dublin"
+ occs := expandLocal(t, "2026-10-23T09:00", "2026-10-23T09:30", tz,
+ "FREQ=DAILY", "", "2026-10-23", "2026-10-29")
+ for _, key := range occs {
+ if !strings.HasSuffix(key, "T09:00") {
+ t.Errorf("occurrence %q drifted off 09:00 across the DST boundary", key)
+ }
+ }
+ if len(occs) != 6 {
+ t.Errorf("got %d occurrences, want 6: %v", len(occs), occs)
+ }
+}
+
+// A rule outside the subset is a signal to store it verbatim, never an
+// error that loses it.
+func TestForeignRulesAreReportedNotSwallowed(t *testing.T) {
+ for _, rule := range []string{
+ "FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR",
+ "FREQ=YEARLY;BYWEEKNO=20",
+ "FREQ=HOURLY",
+ "FREQ=MONTHLY;BYMONTHDAY=-1",
+ } {
+ _, err := calendar.ParseRule(rule)
+ if err == nil {
+ t.Errorf("ParseRule(%q) accepted a rule outside the subset", rule)
+ continue
+ }
+ if !calendar.IsForeignRule(err) {
+ t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err)
+ }
+ }
+}
+
+func TestUntilBoundsTheSeries(t *testing.T) {
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC",
+ "FREQ=DAILY;UNTIL=20260903T235959Z", "", "2026-09-01", "2026-10-01")
+ want := []string{"2026-09-01T09:00", "2026-09-02T09:00", "2026-09-03T09:00"}
+ assertKeys(t, got, want)
+}
+
+// A window that starts mid-series must not renumber or shift anything:
+// the occurrence keys are the series' own, not the window's.
+func TestWindowDoesNotShiftOccurrences(t *testing.T) {
+ got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC",
+ "FREQ=DAILY", "", "2026-09-10", "2026-09-13")
+ want := []string{"2026-09-10T09:00", "2026-09-11T09:00", "2026-09-12T09:00"}
+ assertKeys(t, got, want)
+}
+
+func TestExpansionIsCapped(t *testing.T) {
+ r, err := calendar.ParseRule("FREQ=DAILY")
+ if err != nil {
+ t.Fatalf("ParseRule: %v", err)
+ }
+ from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(2060, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
+ occs := calendar.Expand("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to)
+ if len(occs) > calendar.MaxOccurrencesPerSeries {
+ t.Errorf("expanded %d occurrences, cap is %d", len(occs), calendar.MaxOccurrencesPerSeries)
+ }
+}
+
+func assertKeys(t *testing.T, got, want []string) {
+ t.Helper()
+ if len(got) != len(want) {
+ t.Fatalf("got %d occurrences %v, want %d %v", len(got), got, len(want), want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("occurrence %d = %q, want %q (all: %v)", i, got[i], want[i], got)
+ }
+ }
+}