oficina / calendar Public

app: edit one occurrence, and make the ICS files conformant

Editing one occurrence of a series is where a calendar goes wrong most
visibly, and there are exactly two ways: the moved occurrence appears at
its new time AND the original slot still shows the unmoved one, or the
moved one vanishes. Both come from the same mistake — keying an override
by its new time. It is keyed by the ORIGINAL, so the expander's slot is
claimed, and rendered at the new one. A test moves the 3rd of a daily
series and asserts the 2nd and 4th are untouched and the 3rd appears
exactly once.

"This one and everything after" is a SPLIT, not RANGE=THISANDFUTURE:
almost nothing that will read our export supports the range modifier,
and two ordinary series are understood by everything. The tail gets a
new UID, which is honest — to an importer it IS a different series from
that day. Overrides after the cut move to the tail, or they would be
orphans nothing renders and nothing can reach.

The ICS round-trip test found a real bug on its first run: export wrote
DTSTART as 20260902T0930, and the RFC's DATE-TIME is YYYYMMDDTHHMMSS, so
this app produced files its OWN parser rejected. compactLocal was doing
double duty for URL occurrence keys and wire values, which is what let
the two disagree; icsDateTime is now separate and says why.

Two test bugs worth recording rather than quietly fixing, because both
looked like app bugs first. The isolation test created its events on the
team calendar, because the helper read the first <option> rather than
the SELECTED one a browser submits — the app was right. And the
moved-occurrence test asserted on the page containing "09:30", which
every day does: there are 48 slot links naming every half hour. It now
asserts against the chip.
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 6859dd01d01c37aded43a036351caa574ce0d4bb parent f314a50
9 files changed, +906 −83
  • .impeccable/hook.cache.json +1 −1
  • AGENTS.md +178 −73
  • internal/calendar/handlers_events.go +27 −1
  • internal/calendar/ics_export.go +22 −7
  • internal/calendar/store_override.go +252 −0
  • internal/calendar/templates/event.html +4 −1
  • internal/calendar/templates/event_form.html +19 −0
  • internal/calendartest/ics_test.go +215 −0
  • internal/calendartest/occurrence_test.go +188 −0
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json
index 966f739..8efc281 100644
--- a/.impeccable/hook.cache.json
+++ b/.impeccable/hook.cache.json
@@ -1 +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
+{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788307346881,"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":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":2,"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/AGENTS.md b/AGENTS.md
index 106722f..ddc2e05 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,73 +1,178 @@
-# calendar
-
-A [rastrillo](https://github.com/rastrilloorg/rastrillo) app. Read the
-framework's own SKILL.md (repo root, or in the module cache) before
-writing app code — it is the whole app story in ~15KB. The conventions
-below are load-bearing; the framework enforces most of them
-mechanically.
-
-## Layout
-
-- `internal/<app>/` — the app: `models.go` (plain GORM structs
- + Models), `migrations.go` + `migrations/` (Schema, BootSchema),
- `app.go` (migrate.Apply + chi router), `handlers.go`,
- `render.go` + `templates/`. This is SKILL.md's five-file shape.
-- `manifest/` — the declarative path, optional and equal: TOML or .go
- Resource manifests, each generating a full screen set under `gen/`
- (see manifest/README.md for the mounting recipe). A hand-written
- action file at a generated path (carrying `//go:build rastrillo_actions`)
- takes that one screen over, by existence.
-- `gen/` — generated when manifests are used; never edit. Regenerate
- with `rastrillo generate`.
-
-## Rules the family holds hard
-
-- Every query touching user-owned rows goes through `scope.Owned` —
- reads AND writes, transactions included. A row that isn't yours 404s,
- never 403s.
-- Never bind a request body onto a GORM model: explicit
- `map[string]any` + `.Select` allowlist.
-- Migrations apply at boot, once each, and are never re-run or reversed.
-- Schema changes: edit models.go, then run `rastrillo migration generate`
- and read the SQL it writes — it may be a full table rebuild, not just
- an `ADD COLUMN`. Never edit a migration that has shipped.
- Renames are hand-written (`rastrillo migration new rename_x`) because
- a rename is indistinguishable from a drop plus an add.
-- Money is integer cents. A float never touches a value a person will
- be held to.
-- Screens work with JavaScript disabled; destructive actions get their
- own confirm-page URL.
-- The gate is `make ci` (vet + gofmt + tests) — the same definition
- CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
- stack is cgo-free by design.
-
-## Serving
-
-`rastrillo.Resolve` + `Serve` speak the CARLOS activation contract
-(socket/addr/db argv, LISTEN_FDS, $STATE_DIRECTORY, `sidecar run`) —
-the golden deployment target, not a requirement: `./app -addr :8080`
-serves anywhere. `GET /healthz` and `GET /api/version` are answered
-by the framework, outside app middleware.
-
-## UX conventions (seeded from profile: considered)
-
-Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable.
-
-These are rastrillo's own conventions, and the profile is named for what it does rather than after anyone else's work. For wider reading on interface quality, impeccable.style, the WAI-ARIA Authoring Practices and Inclusive Components are all worth your time — none of them is endorsed by, or authoritative for, this framework.
-
-- [x] Icons — lucide, inline — `internal/<app>/icons`
-- [x] Fields — label, hint, help and error in one envelope, wired with aria-describedby — `ui/field, ui/field-select, ui/field-text`
-- [x] Destructive actions — their own confirm-page URL, never a modal — `ui/confirm-form`
-- [x] State — never colour alone — always a text label too — `ui/status-pill, ui/badge`
-- [ ] Dates — relative, with the absolute date in a title attribute
-- [ ] Errors — server-rendered beside the field, in plain language
-- [x] Empty states — say what the screen is for, not just that it is empty — `ui/empty-state`
-
-`[x]` is enforced by a vendored component: use it, do not hand-roll an
-equivalent. `[ ]` is guidance to apply by hand. A convention absent from
-this list is one rastrillo has no opinion about.
-
-Edit any line. **This file is the source of truth, not the profile name.**
-The profile only seeded this list at scaffold time; nothing re-reads it, so
-changing a line here changes what the app does. A convention deleted from
-this file is not a convention to reinstate.
+# 🤖 Calendar
+
+A calendar for one team, on one instance. Alongside `oficina/docs` and
+`oficina/sheets`, on the same platform, in the same shape.
+
+This file is binding. Where it disagrees with a general skill, this file
+wins. Where it disagrees with the design spec, the spec wins and this
+file is wrong — fix it in the same commit.
+
+**The design:** `docs/superpowers/specs/2026-09-02-calendar-design.md`.
+The raw material it came from, including the rejected alternatives, is
+`docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md`.
+
+---
+
+## The one rule
+
+> **An event's contents are sealed at rest. The bucket never holds a
+> plaintext title, place or note.**
+
+The falsifiable form: **publish this database in full and the worst
+anyone learns is the shape of the team's time** — when they are busy,
+for how long, how often, and in which zones — **and who is on the team.
+Never what any event is, where it is, or what was written about it.**
+
+`TestTheDatabaseNeverHoldsAPlaintextTitle` enforces it from the first
+commit, 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 reading a file the data has not reached.
+
+### What this rule does NOT cover, said here so it is never read as covering it
+
+1. **Times are plaintext, and free/busy is a real leak.** Every view is
+ a range query, `NextDue` reads `MIN(due_at)` before any request, and
+ the platform's own timer registry holds reminder instants anyway.
+ Sealing the instants would claim a property the estate does not
+ have. A published database gives up the team's whole free/busy.
+2. **A reminder email carries the title** through the relay and into a
+ mailbox. That is delivery, not storage, and it is the product
+ working — but the relay's logs are not ours.
+3. **A subscription feed puts plaintext in whoever's account it was
+ pasted into.** The share page says so before the button, not after.
+4. **A compromised live host reads everything.** We never claim "we
+ cannot read your data."
+
+---
+
+## Settled decisions
+
+| # | Ruling | Rejected | Who, when |
+|---|---|---|---|
+| 1 | **Store what the person said**: wall-clock time plus IANA zone is the record; the UTC columns are a derived index | Storing the instant as truth — a tzdata update then moves a meeting and nobody can say what was meant | This session, 2026-09-02 |
+| 2 | Pin rastrillo v0.23.0 and take theme `day` directly from `ui.ThemeCSS` | Consuming `estilo`, which is `day` byte-for-byte today and pins v0.22.0 — adopting it buys nothing and drags us back a release ("the two-pins trap", estilo's own AGENTS.md) | This session, 2026-09-02 |
+| 3 | This app's own components take `cal-` | `rst-` is the framework's and a future release may claim a name; `estilo-` is the suite's, and a month grid is not a suite component until a second app copies one | This session, 2026-09-02 |
+| 4 | The recurrence subset is the six a picker offers; anything else is stored verbatim, flagged, and not expanded | Refusing a rule we cannot expand — an importer that eats a rule it does not understand eats data | This session, 2026-09-02 |
+| 5 | Reminders are per PERSON, not per event | Per-event reminders — two people on one meeting want different warnings, and the fix needs a person column anyway | This session, 2026-09-02 |
+| 6 | Delivery is at-least-once; every ambiguous case resolves towards sending | At-most-once — a reminder that never arrives is worse than one that arrives twice | This session, 2026-09-02 |
+| 7 | Sign-in is a magic link, with no passkey second factor | Passkeys — `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 |
+| 8 | A new event defaults to the person's PERSONAL calendar | Defaulting to the team calendar — the cost of forgetting to change a dropdown should fall on the side of privacy | This session, 2026-09-02 |
+
+Don't relitigate one in a drive-by.
+
+---
+
+## Mechanical rules
+
+Rules here are enforced by tests, not by memory. If you find yourself
+relying on remembering one, make it a test instead.
+
+**Time**
+- **The record is `start_local` + `tz`. The UTC columns are an index.**
+ Anything that writes a UTC column without deriving it from the local
+ pair is a bug.
+- **DST has no correct answer and Go does not promise one.** `clock.go`
+ documents what actually happens and `clock_test.go` pins it to real
+ unix numbers. The gap and the fold resolve DIFFERENTLY — the gap takes
+ the pre-transition offset, the fold takes the second occurrence. Do
+ not reason about one from the other.
+- **A day is 23, 24 or 25 hours.** Use `DayBounds`, never `Add(24h)`.
+- **Every range is half-open.** An inclusive end is how a one-day event
+ shows on two days and how a week query double-counts its boundary.
+- **All-day events are DATES.** Never a datetime at UTC midnight, which
+ puts a birthday on the wrong day west of Greenwich.
+
+**Recurrence**
+- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
+- **A named date a month does not have is NO occurrence** — never
+ clamped. The 31st, 29 February, the fifth Friday.
+- **Overrides are keyed by the occurrence's ORIGINAL local time and
+ rendered at their new one.** The range query is two passes because of
+ it. Skip the second and a moved occurrence vanishes; skip the dedupe
+ and it appears twice.
+- Expansion iterates in **wall clock**. Stepping a UTC instant moves a
+ 09:00 standup by an hour for half the year.
+
+**Data**
+- Every scoped read goes through a grant join. **A row that isn't yours
+ 404s, never 403s** — including for a write, so status codes cannot
+ enumerate the team's calendars.
+- **Never bind a request body onto a model.** Updates take an explicit
+ column allowlist; the columns absent from it (`calendar_id`,
+ `created_by`, `series_id`) are exactly the ones a hidden field must
+ not move.
+- **Never `sessions.UserID`** — under the magic-link plugin the Subject
+ is an address, so it returns `(0, false)` and every query silently
+ scopes to person 0: a calendar that looks empty rather than broken.
+ Use `auth.From(r)`, and `requirePerson` for the id.
+- Migrations are additive-only. Never edit one that has shipped.
+
+**Reminders**
+- **A `time.Ticker` is not a scheduler.** The instance hibernates.
+- **The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
+ refuses any POST with no `Sec-Fetch-Site`, `Origin` or `Referer`, and
+ the scheduler sends none; inside the protected router a tick 403s
+ forever and the platform reads 4xx as "do not retry". It fails
+ silently and permanently. `TestTheTickRouteIsNotBehindCSRF` stands in
+ front of it and asserts WHICH refusal answered.
+- **Work inside the request.** 202-plus-a-goroutine is a reliable way to
+ be hibernated mid-send.
+- **Ticks are expensive.** `CARLOS_HIBERNATE_IDLE` defaults to 15
+ minutes, so each tick buys a quarter hour of billed residency. The
+ declared schedule is 6h and the precision comes from `NextDue`.
+
+**Frontend**
+- Write the **`rst-*` attribute grammar** (`rst-btn="primary"`), never
+ the class spelling. Adopt per screen, never per element.
+- This app's own components take **`cal-`**.
+- **No affordance may require script.** The scriptless page is the
+ product; `datetime.js` and any later enhancement sit on top of a
+ working floor.
+- **Colour never carries meaning alone.** Every chip prints its own
+ time and title; cancelled says "Cancelled"; past is written in the
+ past tense; today has three signals.
+- Light/dark is three states and **auto is the ABSENCE of
+ `data-theme`**. The attribute takes the light/dark SCHEME, never
+ rastrillo's theme NAME.
+
+**Logs**
+- **No title, place, note or address in any log line, error string or
+ metric label.** `carlos logs` is platform-held storage we cannot grep
+ afterwards. Errors carry a stage name and a code.
+
+**Deploys**
+- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
+- **`carlos deploy`, never `carlos ship` alone.** `ship` leaves the
+ release unpromoted and nothing serving it.
+- **`make release`, never a bare `go build`** — it stamps
+ `rastrillo.BuildVersion` and refuses a dirty tree.
+- **A 200 is not verification, and neither is `X-Carlos-Version`.** That
+ header is the router's adopted release. `GET /api/version` is served
+ by the process. Measured here at ~110 seconds behind the promote.
+- **`stable` needs a passkey step-up in the console**, and so does
+ moving an instance between `stable` and `edge`. This app runs on
+ `edge`, like docs. Create an instance on the channel you want.
+
+---
+
+## Shipping cadence
+
+Worktree per piece of work, branch per piece of work, never on `main`.
+Squash-merge only when the work is done, it is approved, and CI is
+actually green — checked, not assumed. Review on a deployed canary,
+never localhost.
+
+`make ci` is the one gate: vet, gofmt, tests, migration check. The same
+definition CI runs. Run it before every push.
+
+## Review
+
+Every spec and every branch is reviewed by the highest-capability model
+available, discovered rather than hardcoded. Record which model
+reviewed. A review by a weaker model than the author is not a review.
+
+## Authorship
+
+🤖 / 👨 markers, `Co-Authored-By:` trailers. AI is a guest with a name
+tag: opt-in, disclosed, and every path works with it switched off.
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go
index a988643..5fa899e 100644
--- a/internal/calendar/handlers_events.go
+++ b/internal/calendar/handlers_events.go
@@ -41,6 +41,10 @@ type eventForm struct {
// Series is set when editing something that recurs, so the form can
// ask which occurrences the edit applies to.
Series bool
+
+ // Occurrence is which one is being edited, when the edit arrived
+ // from an occurrence URL. Empty means the series itself.
+ Occurrence string
}
// newEvent renders the create form, prefilled from the query so that a
@@ -218,6 +222,7 @@ func (h *handlers) editEvent(w http.ResponseWriter, r *http.Request) {
Status: e.Status,
Repeat: RepeatFromRule(e.RRule),
Series: e.RRule != "",
+ Occurrence: r.URL.Query().Get("occurrence"),
}))
}
@@ -239,7 +244,26 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) {
render(w, "event_form", h.page(r, f.Title, f))
return
}
- err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
+ // Which occurrences the edit applies to. Only meaningful on a
+ // series, and "this one" is the default — an edit that silently
+ // changed every occurrence would be the most expensive default in
+ // the app.
+ switch occ := strings.TrimSpace(r.FormValue("occurrence")); r.FormValue("scope") {
+ case ScopeThis:
+ if occ != "" {
+ err = h.store.UpdateOccurrence(r.Context(), p.ID, id, occ, in)
+ break
+ }
+ err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
+ case ScopeFollowing:
+ if occ != "" {
+ err = h.store.SplitSeries(r.Context(), p.ID, id, occ, in)
+ break
+ }
+ err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
+ default:
+ err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
+ }
switch {
case NotFound(err):
ErrorPage(w, r, http.StatusNotFound, "")
@@ -357,6 +381,8 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo
Status: in.Status,
Repeat: r.FormValue("repeat"),
RepeatEnd: r.FormValue("repeat_end"),
+ Occurrence: r.FormValue("occurrence"),
+ Series: r.FormValue("scope") != "",
}
if err := ValidateEventInput(&in); err != nil {
if ve, ok := Invalid(err); ok {
diff --git a/internal/calendar/ics_export.go b/internal/calendar/ics_export.go
index 2556cb4..6e63a0e 100644
--- a/internal/calendar/ics_export.go
+++ b/internal/calendar/ics_export.go
@@ -62,11 +62,11 @@ func writeEvent(b *strings.Builder, e EventView, withAlarms bool) {
// 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))
+ line(b, "DTSTART:"+icsDateTime(e.StartLocal))
+ line(b, "DTEND:"+icsDateTime(e.EndLocal))
} else {
- line(b, "DTSTART;TZID="+e.TZ+":"+compactLocal(e.StartLocal))
- line(b, "DTEND;TZID="+e.TZ+":"+compactLocal(e.EndLocal))
+ line(b, "DTSTART;TZID="+e.TZ+":"+icsDateTime(e.StartLocal))
+ line(b, "DTEND;TZID="+e.TZ+":"+icsDateTime(e.EndLocal))
}
line(b, "SUMMARY:"+escapeText(e.Title))
@@ -86,9 +86,9 @@ func writeEvent(b *strings.Builder, e EventView, withAlarms bool) {
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))
+ line(b, "RECURRENCE-ID;TZID="+e.TZ+":"+icsDateTime(e.RecurrenceIDLocal))
} else {
- line(b, "RECURRENCE-ID:"+compactLocal(e.RecurrenceIDLocal))
+ line(b, "RECURRENCE-ID:"+icsDateTime(e.RecurrenceIDLocal))
}
}
line(b, "STATUS:"+strings.ToUpper(e.Status))
@@ -115,7 +115,7 @@ func writeExdates(b *strings.Builder, e EventView) {
if e.AllDay {
dates = append(dates, compactDate(d))
} else {
- dates = append(dates, compactLocal(d))
+ dates = append(dates, icsDateTime(d))
}
}
if len(dates) == 0 {
@@ -206,3 +206,18 @@ func utcStamp(t time.Time) string {
func compactDate(local string) string {
return strings.ReplaceAll(local, "-", "")
}
+
+// icsDateTime turns "2026-09-02T09:30" into "20260902T093000".
+//
+// The seconds are not decoration: the RFC's DATE-TIME is
+// YYYYMMDDTHHMMSS, and a value without them is not a DATE-TIME. This
+// app stores no seconds, so they are always "00" — but emitting the
+// shorter form produced files that this app's OWN parser rejected,
+// which is how the round-trip test found it.
+//
+// Deliberately not compactLocal, which is the URL form: an occurrence
+// key in a path wants the short spelling, and sharing one function
+// between a URL segment and a wire format is what let them disagree.
+func icsDateTime(local string) string {
+ return compactLocal(local) + "00"
+}
diff --git a/internal/calendar/store_override.go b/internal/calendar/store_override.go
new file mode 100644
index 0000000..ee78c39
--- /dev/null
+++ b/internal/calendar/store_override.go
@@ -0,0 +1,252 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// The three scopes an edit or a delete can have on a series.
+//
+// "This one" is the default everywhere, and it is first in every form.
+// The expensive answer is last with its count said out loud, because
+// the cheap one should never be one keystroke away from the one that
+// destroys a year of standups.
+const (
+ ScopeThis = "this"
+ ScopeFollowing = "following"
+ ScopeAll = "all"
+)
+
+// UpdateOccurrence edits ONE occurrence of a series by writing an
+// override row.
+//
+// The override carries RecurrenceIDLocal = the occurrence's ORIGINAL
+// local start, never its new one. That single field is what makes a
+// moved occurrence behave: the expander skips the original slot because
+// an override claims it, and renders the override at whatever time it
+// now has. Key it by the new time instead and the original slot goes
+// unclaimed — the day shows the unmoved occurrence AND the moved one.
+func (s *Store) UpdateOccurrence(ctx context.Context, personID, seriesID int64, originalLocal string, in EventInput) error {
+ master, err := s.Event(ctx, personID, seriesID)
+ if err != nil {
+ return err
+ }
+ if !master.Writable() {
+ return ErrReadOnly
+ }
+ if master.RRule == "" {
+ // Not a series: editing "this one" is editing the event.
+ return s.UpdateEvent(ctx, personID, seriesID, in)
+ }
+ if err := ValidateEventInput(&in); err != nil {
+ return err
+ }
+
+ existing, found, err := s.findOverride(ctx, seriesID, originalLocal)
+ if err != nil {
+ return err
+ }
+ if found {
+ // An override already exists for this occurrence; edit it in
+ // place rather than adding a second one for the same slot.
+ return s.UpdateEvent(ctx, personID, existing.ID, in)
+ }
+
+ // A new override inherits the series' identity — same calendar,
+ // same UID — because to every other calendar it IS the same event,
+ // just this one instance of it.
+ 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)
+ }
+
+ now := time.Now().UTC()
+ ov := Event{
+ CalendarID: master.CalendarID,
+ UIDHash: master.UIDHash,
+ UIDSealed: master.UIDSealed,
+
+ SeriesID: &seriesID,
+ RecurrenceIDLocal: originalLocal,
+
+ TitleSealed: title,
+ LocationSealed: loc,
+ NotesSealed: notes,
+
+ AllDay: in.AllDay,
+ StartLocal: in.StartLocal,
+ EndLocal: in.EndLocal,
+ TZ: in.TZ,
+ StartUTC: startUTC,
+ EndUTC: endUTC,
+
+ // An override never carries the rule. It is one instance, and a
+ // rule on it would expand a series inside a series.
+ RRule: "",
+ Exdates: "",
+
+ Kind: in.Kind,
+ Status: in.Status,
+ Sequence: master.Sequence + 1,
+ CreatedBy: personID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.g.WithContext(ctx).Create(&ov).Error; err != nil {
+ return fmt.Errorf("calendar: create override: %w", err)
+ }
+ // The reminder for this occurrence now points at a different time.
+ return s.rescheduleAllFor(ctx, seriesID)
+}
+
+// SplitSeries ends a series the day before originalLocal and starts a
+// new one from it — "this and every following".
+//
+// A split rather than RANGE=THISANDFUTURE. The RFC's range modifier is
+// supported by almost nothing that will read our export, and a split is
+// two ordinary series that every client already understands. The cost
+// is that the tail gets a new UID, which is honest: to an importer it
+// IS a different series from that day on.
+func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, originalLocal string, in EventInput) error {
+ master, err := s.Event(ctx, personID, seriesID)
+ if err != nil {
+ return err
+ }
+ if !master.Writable() {
+ return ErrReadOnly
+ }
+ if master.RRule == "" {
+ return s.UpdateEvent(ctx, personID, seriesID, in)
+ }
+ if err := ValidateEventInput(&in); err != nil {
+ return err
+ }
+
+ loc, err := Zone(master.TZ)
+ if err != nil {
+ return err
+ }
+ cut, _, err := ParseLocal(originalLocal, loc)
+ if err != nil {
+ return fmt.Errorf("calendar: split at %q: %w", originalLocal, err)
+ }
+
+ // The head stops the day before the cut. UNTIL is inclusive of its
+ // whole day, so the day before is the last one the head keeps.
+ until := cut.AddDate(0, 0, -1).Format("20060102")
+ headRule := withUntil(master.RRule, until)
+
+ return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ headUntil, _ := RuleUntilUTC(headRule, master.StartLocal, master.TZ, master.AllDay)
+ if err := tx.Model(&Event{}).Where("id = ?", seriesID).
+ Updates(map[string]any{
+ "rrule": headRule,
+ "until_utc": headUntil,
+ "sequence": master.Sequence + 1,
+ "updated_at": time.Now().UTC(),
+ }).Error; err != nil {
+ return fmt.Errorf("calendar: end the head series: %w", err)
+ }
+
+ // The tail is a new series with the master's rule, starting at
+ // the edited time. A new UID: to any importer this is a
+ // different series from here on, and pretending otherwise
+ // produces two events claiming one identity.
+ tail := in
+ tail.CalendarID = master.CalendarID
+ tail.RRule = master.RRule
+ tail.Exdates = ""
+ ev, err := s.buildEvent(personID, tail)
+ if err != nil {
+ return err
+ }
+ if err := tx.Create(&ev).Error; err != nil {
+ return fmt.Errorf("calendar: create the tail series: %w", err)
+ }
+
+ // Overrides after the cut belong to the tail, not the head.
+ // Left on the head they would be orphans: rows nothing renders
+ // and nothing can reach.
+ return tx.Model(&Event{}).
+ Where("series_id = ? AND start_utc >= ?", seriesID, cut.Unix()).
+ Update("series_id", ev.ID).Error
+ })
+}
+
+// findOverride looks one up by its ORIGINAL occurrence.
+func (s *Store) findOverride(ctx context.Context, seriesID int64, originalLocal string) (Event, bool, error) {
+ var e Event
+ err := s.g.WithContext(ctx).
+ Where("series_id = ? AND recurrence_id_local = ? AND deleted_at IS NULL",
+ seriesID, originalLocal).
+ First(&e).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Event{}, false, nil
+ }
+ if err != nil {
+ return Event{}, false, fmt.Errorf("calendar: find override: %w", err)
+ }
+ return e, true, nil
+}
+
+// withUntil replaces or appends an UNTIL on a rule, and drops a COUNT
+// if there was one — COUNT and UNTIL together is not a legal rule, and
+// a head that kept its original COUNT would go on generating past its
+// own end.
+func withUntil(rrule, until string) string {
+ r, err := ParseRule(rrule)
+ if err != nil {
+ return rrule
+ }
+ out := "FREQ=" + r.Freq
+ if r.Interval > 1 {
+ out += fmt.Sprintf(";INTERVAL=%d", r.Interval)
+ }
+ if len(r.ByDay) > 0 {
+ days := make([]string, 0, len(r.ByDay))
+ for _, d := range r.ByDay {
+ days = append(days, ICSWeekday(d))
+ }
+ out += ";BYDAY=" + joinComma(days)
+ }
+ if r.Ordinal != 0 {
+ out += fmt.Sprintf(";BYDAY=%d%s", r.Ordinal, ICSWeekday(r.OrdinalDay))
+ }
+ if r.MonthDay > 0 {
+ out += fmt.Sprintf(";BYMONTHDAY=%d", r.MonthDay)
+ }
+ return out + ";UNTIL=" + until
+}
+
+func joinComma(s []string) string {
+ out := ""
+ for i, v := range s {
+ if i > 0 {
+ out += ","
+ }
+ out += v
+ }
+ return out
+}
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html
index d67e3b5..589292b 100644
--- a/internal/calendar/templates/event.html
+++ b/internal/calendar/templates/event.html
@@ -6,7 +6,10 @@
<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>
+ {{/* The occurrence rides through to the form, so "edit this one"
+ knows which one it is editing. */}}
+ <a rst-btn="primary"
+ href="/events/{{.Data.Event.ID}}/edit{{if .Data.Occurrence}}?occurrence={{.Data.Occurrence}}{{end}}">Edit</a>
{{end}}
</header>
diff --git a/internal/calendar/templates/event_form.html b/internal/calendar/templates/event_form.html
index 0e4ea7d..1e63cda 100644
--- a/internal/calendar/templates/event_form.html
+++ b/internal/calendar/templates/event_form.html
@@ -11,6 +11,25 @@
{{end}}
<form rst-form method="post" action="{{.Data.Action}}">
+ {{if and .Data.Series .Data.Occurrence}}
+ <input type="hidden" name="occurrence" value="{{.Data.Occurrence}}">
+ {{/* Asked BEFORE the fields, not after: which occurrences this
+ applies to changes what every field below means. "Just this one"
+ is first and checked, because an edit that silently changed a
+ year of standups is the most expensive default in the app. */}}
+ {{template "choice-field" dict
+ "Legend" "This repeats. What should this change?"
+ "Name" "scope"
+ "Type" "radio"
+ "Options" (list
+ (dict "Value" "this" "Title" "Just this one"
+ "Desc" "The rest of the series keeps its current time and details."
+ "Checked" true)
+ (dict "Value" "following" "Title" "This one and everything after it"
+ "Desc" "Earlier occurrences are left alone. The series is split in two from here.")
+ (dict "Value" "all" "Title" "Every occurrence"
+ "Desc" "Past ones included."))}}
+ {{end}}
{{template "field" dict
"ID" "title" "Name" "title" "Label" "What is it?"
"Value" .Data.EventTitle "Required" true "Autofocus" true
diff --git a/internal/calendartest/ics_test.go b/internal/calendartest/ics_test.go
new file mode 100644
index 0000000..5ba9693
--- /dev/null
+++ b/internal/calendartest/ics_test.go
@@ -0,0 +1,215 @@
+package calendartest
+
+import (
+ "strings"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// A file this app wrote must read back as what it meant. The round trip
+// is the only test that catches an escape applied in the wrong order or
+// a fold that split a rune.
+func TestICSRoundTrip(t *testing.T) {
+ events := []calendar.EventView{{
+ Event: calendar.Event{
+ ID: 1, AllDay: false,
+ StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00",
+ TZ: "Europe/Dublin", Kind: calendar.KindMeeting,
+ Status: calendar.StatusConfirmed, Sequence: 3,
+ RRule: "FREQ=WEEKLY;BYDAY=WE",
+ },
+ // Every character the RFC escapes, in one title, plus a
+ // non-ASCII rune so a fold that split bytes shows up.
+ Title: `Budget; review, "phase 2" \ café — año`,
+ Location: "Room 3, upstairs; past the kitchen",
+ Notes: "First line\nSecond line",
+ UIDSealedOpen: "abc123@calendar.oficina",
+ }}
+
+ out := calendar.WriteICS("Team", events, false)
+
+ if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
+ t.Error("the stream does not begin with VCALENDAR and CRLF")
+ }
+ if !strings.Contains(out, "DTSTART;TZID=Europe/Dublin:20260902T0930") {
+ t.Errorf("DTSTART is missing its TZID:\n%s", out)
+ }
+ if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=WE") {
+ t.Error("the rule did not survive export")
+ }
+
+ parsed, res, err := calendar.ParseICS(strings.NewReader(out), "UTC")
+ if err != nil {
+ t.Fatalf("ParseICS: %v", err)
+ }
+ if len(parsed) != 1 {
+ t.Fatalf("round trip produced %d events, want 1 (skipped %d)", len(parsed), res.Skipped)
+ }
+ got := parsed[0]
+
+ if got.Title != events[0].Title {
+ t.Errorf("title round-tripped as %q, want %q", got.Title, events[0].Title)
+ }
+ if got.Location != events[0].Location {
+ t.Errorf("location round-tripped as %q, want %q", got.Location, events[0].Location)
+ }
+ if got.Notes != events[0].Notes {
+ t.Errorf("notes round-tripped as %q, want %q", got.Notes, events[0].Notes)
+ }
+ if got.StartLocal != "2026-09-02T09:30" {
+ t.Errorf("start round-tripped as %q", got.StartLocal)
+ }
+ if got.TZ != "Europe/Dublin" {
+ t.Errorf("zone round-tripped as %q", got.TZ)
+ }
+ if got.RRule != "FREQ=WEEKLY;BYDAY=WE" {
+ t.Errorf("rule round-tripped as %q", got.RRule)
+ }
+}
+
+// A long line is folded at 75 octets and continued with a space. The
+// fold must never split a multi-byte rune, or the file stops being
+// valid UTF-8 and importers reject it in ways that look like a corrupt
+// calendar.
+func TestLongLinesFoldWithoutBreakingRunes(t *testing.T) {
+ long := strings.Repeat("café ", 40) // 200 runes, 240 bytes
+ out := calendar.WriteICS("Team", []calendar.EventView{{
+ Event: calendar.Event{
+ ID: 1, StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00",
+ TZ: "UTC", Kind: calendar.KindMeeting, Status: calendar.StatusConfirmed,
+ },
+ Title: long,
+ }}, false)
+
+ for _, line := range strings.Split(out, "\r\n") {
+ if len(line) > 75 {
+ t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line)
+ }
+ }
+ if !strings.Contains(out, "\r\n ") {
+ t.Error("nothing was folded, so the fold was not exercised")
+ }
+ parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC")
+ if err != nil {
+ t.Fatalf("ParseICS: %v", err)
+ }
+ if len(parsed) != 1 || parsed[0].Title != long {
+ t.Error("a folded title did not unfold back to itself")
+ }
+}
+
+// An all-day event is a DATE, and must stay one. Exported as a datetime
+// at midnight it lands on the wrong day for everybody west of
+// Greenwich.
+func TestAllDayExportsAsADate(t *testing.T) {
+ out := calendar.WriteICS("Team", []calendar.EventView{{
+ Event: calendar.Event{
+ ID: 1, AllDay: true,
+ StartLocal: "2026-09-02", EndLocal: "2026-09-03",
+ Kind: calendar.KindNote, Status: calendar.StatusConfirmed,
+ },
+ Title: "Somebody's birthday",
+ }}, false)
+
+ if !strings.Contains(out, "DTSTART;VALUE=DATE:20260902") {
+ t.Errorf("an all-day event did not export as a DATE:\n%s", out)
+ }
+ if strings.Contains(out, "DTSTART;VALUE=DATE:20260902T") {
+ t.Error("an all-day DTSTART carries a time")
+ }
+
+ parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC")
+ if err != nil || len(parsed) != 1 {
+ t.Fatalf("ParseICS: %v (%d events)", err, len(parsed))
+ }
+ if !parsed[0].AllDay {
+ t.Error("an all-day event round-tripped as timed")
+ }
+ if parsed[0].StartLocal != "2026-09-02" {
+ t.Errorf("all-day start round-tripped as %q", parsed[0].StartLocal)
+ }
+}
+
+// A file from another calendar must import as much as it honestly can,
+// and report the rest. This is a Google-shaped export with a foreign
+// rule, an attendee, and a VTIMEZONE block that must not leak its own
+// properties onto the event.
+func TestImportingAForeignFile(t *testing.T) {
+ const file = "BEGIN:VCALENDAR\r\n" +
+ "VERSION:2.0\r\nPRODID:-//Example//EN\r\n" +
+ "BEGIN:VTIMEZONE\r\nTZID:Europe/Dublin\r\n" +
+ "BEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nTZOFFSETFROM:+0100\r\n" +
+ "TZOFFSETTO:+0000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" +
+ "BEGIN:VEVENT\r\nUID:x1@example.com\r\nSUMMARY:Retro\r\n" +
+ "DTSTART;TZID=Europe/Dublin:20260902T140000\r\n" +
+ "DTEND;TZID=Europe/Dublin:20260902T150000\r\n" +
+ "RRULE:FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR\r\n" +
+ "ATTENDEE;CN=Someone:mailto:someone@example.com\r\n" +
+ "END:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\nUID:x2@example.com\r\nSUMMARY:Floating thing\r\n" +
+ "DTSTART:20260903T110000\r\nDTEND:20260903T120000\r\nEND:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+
+ parsed, res, err := calendar.ParseICS(strings.NewReader(file), "UTC")
+ if err != nil {
+ t.Fatalf("ParseICS: %v", err)
+ }
+ if len(parsed) != 2 {
+ t.Fatalf("imported %d events, want 2", len(parsed))
+ }
+
+ // The VTIMEZONE's own DTSTART must not have landed on the event.
+ if parsed[0].StartLocal != "2026-09-02T14:00" {
+ t.Errorf("the first event's start is %q — a VTIMEZONE property leaked onto it", parsed[0].StartLocal)
+ }
+ if parsed[0].TZ != "Europe/Dublin" {
+ t.Errorf("zone imported as %q", parsed[0].TZ)
+ }
+
+ // A rule outside the subset is KEPT, not dropped, and counted.
+ if !strings.Contains(parsed[0].RRule, "BYSETPOS") {
+ t.Error("a foreign rule was dropped rather than stored verbatim")
+ }
+ if res.ForeignRules != 1 {
+ t.Errorf("ForeignRules = %d, want 1", res.ForeignRules)
+ }
+ // Guests are not modelled, and the count says so rather than the
+ // lines vanishing silently.
+ if res.Attendees != 1 {
+ t.Errorf("Attendees = %d, want 1", res.Attendees)
+ }
+ // A zoneless event stays floating rather than being guessed into
+ // the importer's zone.
+ if parsed[1].TZ != "" {
+ t.Errorf("a floating event was given zone %q", parsed[1].TZ)
+ }
+ if res.Floating != 1 {
+ t.Errorf("Floating = %d, want 1", res.Floating)
+ }
+}
+
+// A VEVENT with no DTEND takes the RFC's defaults rather than being
+// skipped.
+func TestImportAppliesDurationAndImpliedEnd(t *testing.T) {
+ const file = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
+ "UID:d1\r\nSUMMARY:With a duration\r\n" +
+ "DTSTART;TZID=UTC:20260902T090000\r\nDURATION:PT1H30M\r\n" +
+ "END:VEVENT\r\nBEGIN:VEVENT\r\n" +
+ "UID:d2\r\nSUMMARY:A whole day\r\n" +
+ "DTSTART;VALUE=DATE:20260902\r\n" +
+ "END:VEVENT\r\nEND:VCALENDAR\r\n"
+
+ parsed, _, err := calendar.ParseICS(strings.NewReader(file), "UTC")
+ if err != nil || len(parsed) != 2 {
+ t.Fatalf("ParseICS: %v (%d events)", err, len(parsed))
+ }
+ if parsed[0].EndLocal != "2026-09-02T10:30" {
+ t.Errorf("DURATION:PT1H30M gave end %q, want 2026-09-02T10:30", parsed[0].EndLocal)
+ }
+ // An all-day event with no end is one day, and the end is
+ // exclusive — so the following date.
+ if parsed[1].EndLocal != "2026-09-03" {
+ t.Errorf("an all-day event with no DTEND ends %q, want 2026-09-03", parsed[1].EndLocal)
+ }
+}
diff --git a/internal/calendartest/occurrence_test.go b/internal/calendartest/occurrence_test.go
new file mode 100644
index 0000000..7ebc219
--- /dev/null
+++ b/internal/calendartest/occurrence_test.go
@@ -0,0 +1,188 @@
+package calendartest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// Editing one occurrence of a series is where calendars go wrong most
+// visibly: the moved occurrence appears at its new time AND the
+// original slot still shows the unmoved one, or the moved one vanishes
+// entirely.
+//
+// The rule that prevents both is that an override is keyed by the
+// occurrence's ORIGINAL local time and rendered at its new one.
+func TestMovingOneOccurrenceLeavesTheRestAlone(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ loc := 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"},
+ })
+ id := strings.TrimPrefix(loc, "/events/")
+
+ // Move ONLY the 3rd, from 09:30 to 14:00.
+ rec := postAs(t, h, session, "/events/"+id, url.Values{
+ "scope": {"this"},
+ "occurrence": {"2026-09-03T09:30"},
+ "title": {"Daily standup"},
+ "calendar_id": {calendarIDOf(t, h, session)},
+ "start": {"2026-09-03T14:00"},
+ "end": {"2026-09-03T14:15"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"none"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("moving one occurrence: status %d\n%s", rec.Code, rec.Body.String())
+ }
+
+ // The moved day shows it once, at the new time, and not at the old.
+ //
+ // Assert against the CHIP's own time, not against the page: every
+ // day carries 48 slot links whose accessible names contain every
+ // half hour, so a bare Contains("09:30") matches on every day
+ // whether or not anything is scheduled then.
+ moved := getAs(t, h, session, "/day/2026-09-03").Body.String()
+ if !hasChipAt(moved, "14:00") {
+ t.Errorf("the moved occurrence is not at its new time:\n%s", moved)
+ }
+ if hasChipAt(moved, "09:30") {
+ t.Error("the moved occurrence still appears at its ORIGINAL time — " +
+ "the override is not claiming the slot the expander generates")
+ }
+ if n := strings.Count(moved, "Daily standup"); n != 1 {
+ t.Errorf("the moved day shows the event %d times, want exactly 1", n)
+ }
+
+ // Every other day is untouched.
+ for _, day := range []string{"2026-09-02", "2026-09-04"} {
+ body := getAs(t, h, session, "/day/"+day).Body.String()
+ if !hasChipAt(body, "09:30") {
+ t.Errorf("%s lost its 09:30 occurrence when a different day was moved", day)
+ }
+ if n := strings.Count(body, "Daily standup"); n != 1 {
+ t.Errorf("%s shows the event %d times, want exactly 1", day, n)
+ }
+ }
+}
+
+// hasChipAt reports whether a rendered page carries an event chip
+// starting at hhmm — as opposed to merely containing that time
+// somewhere, which every day does in its slot links.
+func hasChipAt(body, hhmm string) bool {
+ return strings.Contains(body, "<span cal-chip-time>"+hhmm+"</span>")
+}
+
+// Deleting one occurrence writes an EXDATE, which removes exactly that
+// day and nothing else.
+func TestDeletingOneOccurrenceRemovesOnlyThatDay(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ loc := 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"},
+ })
+ id := strings.TrimPrefix(loc, "/events/")
+
+ rec := postAs(t, h, session, "/events/"+id+"/delete", url.Values{
+ "scope": {"this"},
+ "occurrence": {"2026-09-03T09:30"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("deleting one occurrence: status %d", rec.Code)
+ }
+
+ if body := getAs(t, h, session, "/day/2026-09-03").Body.String(); strings.Contains(body, "Daily standup") {
+ t.Error("the deleted occurrence is still on its day")
+ }
+ for _, day := range []string{"2026-09-02", "2026-09-04"} {
+ if body := getAs(t, h, session, "/day/"+day).Body.String(); !strings.Contains(body, "Daily standup") {
+ t.Errorf("%s lost its occurrence when a different day was deleted", day)
+ }
+ }
+}
+
+// "This one and everything after it" splits the series: the head keeps
+// its earlier occurrences unchanged, the tail starts from the edit.
+func TestSplittingASeriesLeavesTheHeadAlone(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": {"daily"},
+ })
+ id := strings.TrimPrefix(loc, "/events/")
+
+ rec := postAs(t, h, session, "/events/"+id, url.Values{
+ "scope": {"following"},
+ "occurrence": {"2026-09-10T09:30"},
+ "title": {"Standup"},
+ "calendar_id": {calendarIDOf(t, h, session)},
+ "start": {"2026-09-10T11:00"},
+ "end": {"2026-09-10T11:15"},
+ "kind": {"meeting"},
+ "status": {"confirmed"},
+ "repeat": {"daily"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("splitting the series: status %d\n%s", rec.Code, rec.Body.String())
+ }
+
+ // Before the split: still 09:30.
+ before := getAs(t, h, session, "/day/2026-09-05").Body.String()
+ if !strings.Contains(before, "09:30") {
+ t.Errorf("an occurrence before the split lost its original time:\n%s", before)
+ }
+ // After: 11:00, and only once.
+ after := getAs(t, h, session, "/day/2026-09-11").Body.String()
+ if !strings.Contains(after, "11:00") {
+ t.Errorf("an occurrence after the split is not at the new time:\n%s", after)
+ }
+ if n := strings.Count(after, "Standup"); n != 1 {
+ t.Errorf("a day after the split shows the event %d times, want 1 — "+
+ "the head series was not ended where the tail begins", n)
+ }
+}
+
+// calendarIDOf reads the calendar a new event would default to.
+func calendarIDOf(t *testing.T, h http.Handler, session *http.Cookie) string {
+ t.Helper()
+ page := getAs(t, h, session, "/events/new").Body.String()
+ i := strings.Index(page, `<option value="`)
+ for i >= 0 {
+ rest := page[i+len(`<option value="`):]
+ end := strings.Index(rest, `"`)
+ if end < 0 {
+ break
+ }
+ id := rest[:end]
+ if strings.Contains(rest[end:min(end+80, len(rest))], "selected") {
+ return id
+ }
+ next := strings.Index(rest, `<option value="`)
+ if next < 0 {
+ break
+ }
+ i = i + len(`<option value="`) + next
+ }
+ t.Fatal("no selected calendar on the create form")
+ return ""
+}