oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

Calendar — design

Status: built and deployed, 2026-09-02. Authored by Claude Opus 5. Not yet reviewed by a model at least as capable as the author, and not yet approved by Paul. Both are required before this branch merges; AGENTS.md §Review is the rule and this line is the record that it has not happened.

Raw material, including the rejected alternatives: docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md (Fable, 2026-09-02).

This spec was written after the code, which inverts the house order. Recorded rather than hidden: the session built first and documented second, and the cost is that the rulings below were made while writing the code rather than before it. What follows is what was actually built, not what was planned.

---

1. The one rule

> An event's contents are sealed at rest. The bucket never holds a > plaintext title, place or note.

Falsifiable: publish the database in full and the worst anyone learns is the shape of the team's time and who is on the team — never what any event is, where, or what was written about it.

Enforced by TestTheDatabaseNeverHoldsAPlaintextTitle, which greps the raw SQLite file and the WAL after a wal_checkpoint(TRUNCATE).

1.1 Why times are plaintext

Three reasons, and the third is the one that settles it:

  1. Every view is a range query over start_utc / end_utc.
  2. NextDue reads MIN(due_at) at boot, before any request, so the reminder queue cannot be behind a handle that opens per request.
  3. The platform's own timer registry holds every reminder instant in plaintext anyway — carlos.ScheduleAt sends at over the control socket. Sealing our copy would claim a property the estate does not have.

The consequence is stated rather than buried: a published database gives up the team's whole free/busy, which is a bigger leak than sheets' "size of every sheet". AGENTS.md says so in those words.

The deferred alternative — a sealed index the boot path can open before any request — has a design and no implementation. Trigger: somebody objects to free/busy being legible.

---

2. Ruling 1 — store what the person said

An event's record is the wall-clock time typed plus the IANA zone meant. The UTC columns are derived.

Rejected: storing the instant as truth. A government moves a DST boundary, a release ships new tzdata, and a 09:00 standup is now at 08:00 or 10:00 with nothing able to say what was meant.

Columns: start_local, end_local, tz are the record; start_utc, end_utc, until_utc are an index.

DST. There is no correct answer and Go does not promise one, so clock.go documents what happens and clock_test.go pins it to real unix numbers, measured against Europe/Dublin 2026:

  • The gap (spring, 01:00→02:00): ParseInLocation uses the pre-transition offset, so 01:30 lands at 01:30 UTC and reads back as 02:30 local. It moves FORWARD, which is the half worth relying on.
  • The fold (autumn, 02:00→01:00): the SECOND of the two occurrences.

These are not one rule. An earlier draft of that comment claimed both took the pre-transition offset — true of the gap, false of the fold. A probe corrected it. Do not reason about the fold from the gap.

A day is 23, 24 or 25 hours, and DayBounds uses AddDate. Tested.

All-day events are DATES, not datetimes at UTC midnight.

Every range is half-open, everywhere.

---

3. Recurrence

3.1 The subset

FREQ DAILY / WEEKLY / MONTHLY / YEARLY, INTERVAL, BYDAY for weekly, a single ordinal BYDAY or a single BYMONTHDAY for monthly, COUNT or UNTIL. The UI exposes six choices, labelled against the event's own start date ("Every Wednesday", not "Weekly").

A rule outside the subset is stored verbatim, flagged rrule_foreign, shown on the event's page, exported unchanged, and not expanded. An importer that eats a rule it does not understand eats data.

3.2 The three orderings, each tested

  • COUNT counts GENERATED occurrences; EXDATE removes afterwards. Filtering first gives a four-occurrence series five.
  • A named date a month does not have is NO occurrence. The 31st is skipped in February, never clamped. Same for 29 February yearly and the fifth Friday.
  • Expansion iterates in WALL CLOCK. Stepping a UTC instant by 24 hours moves a 09:00 standup by an hour for half the year.

Capped at MaxOccurrencesPerSeries = 1000 per master per request.

3.3 Overrides

An override is a row with series_id set and recurrence_id_local = the occurrence's ORIGINAL local start. It is rendered at whatever time it now has.

The range query is therefore two passes:

  1. Expand each master; substitute any override matching the generated occurrence's original key.
  2. Add overrides whose own times land in the window but whose original did not — the occurrence dragged in from last week.

Skip pass 2 and a moved occurrence vanishes. Skip the dedupe and it appears twice. Both are tested.

"This and following" is a SPLIT, not RANGE=THISANDFUTURE: almost nothing that reads our export supports the range modifier. The head gets an UNTIL the day before the cut, the tail is a new series with a new UID, and overrides after the cut move to the tail so none are orphaned.

---

4. Scriptless layout

The floor is the product. Nothing here requires script.

  • Month is a <table>. A month IS tabular data; the table gives a screen reader its headers for free. Six rows always, so the page does not change height between months. No spanning bars — each day says what is on it, and +N more links to the day.
  • Day and week are packed on the server: events clustered transitively by overlap, then greedy lane assignment per cluster, then percentages emitted as inline style (rastrillo's CSP permits inline styles — its own partials carry them). Per cluster, not per day, so a lone four o'clock is not squeezed by two meetings at nine.
  • Click-a-time-to-create is 48 half-hour <a> elements per day column, each with an rst-sr-only name saying its time, each an ordinary GET to a prefilled form.
  • All-day events are not packed into the column; giving a birthday a height would make it look like a 24-hour meeting.
  • 320px: month reflows to a stacked list; week keeps two dimensions and scrolls.

Enhancement, currently: rastrillo's own datetime.js on the date fields. Nothing else. Deferred: create.js, turning a pointer drag across empty slots into one navigation to /events/new. Trigger: Paul's first canary review says selecting a range by form is painful.

---

5. Reminders under hibernation

A time.Ticker is not a scheduler; the process is not there when it fires.

One queue row per RULE, holding only the next un-fired occurrence. A forever-daily standup costs one row; firing it computes the next.

Three ways a reminder fires, and the redundancy is the design:

  1. Options.NextDue answers the activator's poll with MIN(due_at). The platform hibernates knowing when to wake, and wakes the process for it. This is the precision path.
  2. The declared schedule, every 6 hours, POSTs /jobs/reminders. Six and not five minutes: CARLOS_HIBERNATE_IDLE defaults to 15 minutes, so every tick buys a quarter hour of billed residency — a 5-minute schedule accrues ~730 instance-hours a month for seconds of work.
  3. Any signed-in page view drains one opportunistically, bounded to one per request.

The tick route is mounted OUTSIDE csrf.Protect. csrf.Protect refuses any POST carrying 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" — a silent, permanent failure. TestTheTickRouteIsNotBehindCSRF asserts WHICH refusal answered, and the deployed instance was checked from outside.

Work happens inside the request. 202-plus-a-goroutine is a reliable way to be hibernated mid-send.

At-least-once, and every ambiguous case resolves towards sending: a reminder that never arrives is worse than one that arrives twice. A 5-minute claim lease bounds the duplicate window. late_by_sec is recorded and the message says how late it was.

Undeliverable after 5 attempts, counted and surfaced on settings — retrying forever is a queue that never drains on a platform billed by residency.

---

6. ICS

Import never rejects a file. It takes what it honestly can and reports the rest by name: events added, skipped, floating, foreign rules, dropped attendees, unresolvable zones. VTIMEZONE and VALARM blocks are skipped wholesale so their properties cannot land on the event being built — tested.

Export emits IANA TZIDs and no VTIMEZONE. Google, Apple and Thunderbird resolve zones from their own databases; Outlook desktop historically does not. Stated in the README. Trigger for building it: an Outlook import of our export shows a wrong time.

Lines fold at 75 octets on rune boundaries. The round-trip test found a real conformance bug on its first run: export wrote 20260902T0930 where the RFC's DATE-TIME is YYYYMMDDTHHMMSS, so the app produced files its own parser rejected. compactLocal was serving both URL occurrence keys and wire values, which is what let them disagree; icsDateTime is now separate.

Subscription feeds are capability URLs outside the session, because Google and Apple will not sign in. Only the token's hash is stored, so the URL is shown once and the page says so. ETag/304, because on a platform billed by residency a 304 is the difference between a feed that costs a little and one that costs a lot. poll_count is shown on the share page: a feed is not free and the person deciding should see it.

---

7. What shipped, and what did not

Shipped and deployed: sign-in, calendars and grants, events, five views, recurrence with occurrence editing and series splitting, reminders, ICS import/export/feeds, settings, theme switching.

Shipped since: quick add — a natural-language box beside New event, with a live server-parsed detail pane and recorded (never invited) attendees. Its own spec is 2026-09-02-quick-add-design.md.

Not built, each with a trigger: attendees and guests, web push, external feed subscription, VTIMEZONE, expanding foreign rules, drag to move, skip-ahead expansion, sealed instants, search, background import, RDATE exactness, working-hours shading, free/busy across members, per-event colours, attachments, print, "find a time".

Known incomplete:

  • The rrule-go oracle the brainstorm proposed as a test cross-check is not wired. The golden vectors are hand-derived, which means they encode my reading of the RFC rather than an independent one.
  • Locale catalogs are not written; user-visible strings are English literals rather than catalog keys. This is a real divergence from the suite's twelve-locale floor and is the largest single piece of debt in the branch.
  • The chromedp browser drive is written and still does not run in this environment: Chromium starts and sits at about:blank until Go'''s 600-second timeout, with chromedp'''s goroutines blocked on select. TestBrowserWalk is therefore still dead.

The house rule is now met by a different gate. make browser runs TestQuickAddInARealBrowser, which drives Playwright — Go starts the app and signs in, node drives the browser — and it crosses from computed value to painted output twice: it measures the popover'''s real getBoundingClientRect(), and it runs the whole feature a second time with JavaScript disabled to prove the scriptless floor rather than assert it.

It earned its place on its first run, catching a bug no Go assertion could see: the popover rendered ~28px off the right-hand edge of a 1280px viewport, because it was anchored to the start edge while its trigger sits at the far right of the header. Every string test passed. Fixed by anchoring to the end edge.

Remaining work is porting TestBrowserWalk off chromedp, not writing a gate.

  • UI copy has not been through the copy-review gate.
  • rastrillo's mail.SMTP ignores its context and sets no dial or protocol deadline, so an abandoned send holds a goroutine and a socket until the kernel closes it. Bounded to four in flight here by boundedSender; fixed properly upstream.

Shipped since, again: booking pages — a public "book time with me" link per host, with plain-text availability, a mailed link whose button confirms, a tokenised ICS, and booker-side reschedule and cancel. Its own spec is 2026-09-07-booking-design.md, reviewed eight times by GPT-6 before code; the review found six defects in this engine's recurrence expansion that every view already had, all fixed on the same branch.

Home account connection — 2026-09-10

The approved suite sign-in extension connects a verified Home identity to an existing Calendar person after fresh local verification and explicit confirmation. The Home subject never becomes an email address or creates a person, calendar, event or reminder. Each request resolves the stored person ID and checks current admission; calendar grants still decide which events that person can read or edit. Person IDs use SQLite AUTOINCREMENT and must never be reassigned or reused.

Home is optional. Configure the CALENDARHOMEISSUER, CALENDARHOMECONSUMERID, CALENDARHOMEKEYSBASE64 and CALENDARHOMEDEK_BASE64 values together. The final value is a dedicated 32-byte connection key, separate from event encryption. Partial configuration refuses startup. Without these values, local sign-in keeps its existing routes and behavior. The shared Home connection package supplies the proof flow and account screens; Calendar keeps its own page shell and grants.