oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Calendar — design
2
3**Status:** built and deployed, 2026-09-02. Authored by Claude Opus 5.
4**Not yet reviewed by a model at least as capable as the author, and not
5yet approved by Paul.** Both are required before this branch merges;
6AGENTS.md §Review is the rule and this line is the record that it has
7not happened.
8
9Raw material, including the rejected alternatives:
10`docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md`
11(Fable, 2026-09-02).
12
13**This spec was written after the code, which inverts the house order.**
14Recorded rather than hidden: the session built first and documented
15second, and the cost is that the rulings below were made while writing
16the code rather than before it. What follows is what was actually built,
17not what was planned.
18
19---
20
21## 1. The one rule
22
23> **An event's contents are sealed at rest. The bucket never holds a
24> plaintext title, place or note.**
25
26Falsifiable: publish the database in full and the worst anyone learns is
27the shape of the team's time and who is on the team — never what any
28event is, where, or what was written about it.
29
30Enforced by `TestTheDatabaseNeverHoldsAPlaintextTitle`, which greps the
31raw SQLite file and the WAL after a `wal_checkpoint(TRUNCATE)`.
32
33### 1.1 Why times are plaintext
34
35Three reasons, and the third is the one that settles it:
36
371. Every view is a range query over `start_utc` / `end_utc`.
382. `NextDue` reads `MIN(due_at)` at boot, before any request, so the
39 reminder queue cannot be behind a handle that opens per request.
403. **The platform's own timer registry holds every reminder instant in
41 plaintext anyway** — `carlos.ScheduleAt` sends `at` over the control
42 socket. Sealing our copy would claim a property the estate does not
43 have.
44
45The consequence is stated rather than buried: a published database gives
46up the team's whole free/busy, which is a bigger leak than sheets' "size
47of every sheet". AGENTS.md says so in those words.
48
49The deferred alternative — a sealed index the boot path can open before
50any request — has a design and no implementation. Trigger: somebody
51objects to free/busy being legible.
52
53---
54
55## 2. Ruling 1 — store what the person said
56
57**An event's record is the wall-clock time typed plus the IANA zone
58meant. The UTC columns are derived.**
59
60Rejected: storing the instant as truth. A government moves a DST
61boundary, a release ships new tzdata, and a 09:00 standup is now at
6208:00 or 10:00 with nothing able to say what was meant.
63
64Columns: `start_local`, `end_local`, `tz` are the record;
65`start_utc`, `end_utc`, `until_utc` are an index.
66
67**DST.** There is no correct answer and Go does not promise one, so
68`clock.go` documents what happens and `clock_test.go` pins it to real
69unix numbers, measured against Europe/Dublin 2026:
70
71- The **gap** (spring, 01:00→02:00): `ParseInLocation` uses the
72 pre-transition offset, so 01:30 lands at 01:30 UTC and reads back as
73 02:30 local. It moves FORWARD, which is the half worth relying on.
74- The **fold** (autumn, 02:00→01:00): the SECOND of the two occurrences.
75
76**These are not one rule.** An earlier draft of that comment claimed
77both took the pre-transition offset — true of the gap, false of the
78fold. A probe corrected it. Do not reason about the fold from the gap.
79
80**A day is 23, 24 or 25 hours**, and `DayBounds` uses `AddDate`. Tested.
81
82**All-day events are DATES**, not datetimes at UTC midnight.
83
84**Every range is half-open**, everywhere.
85
86---
87
88## 3. Recurrence
89
90### 3.1 The subset
91
92`FREQ` DAILY / WEEKLY / MONTHLY / YEARLY, `INTERVAL`, `BYDAY` for
93weekly, a single ordinal `BYDAY` or a single `BYMONTHDAY` for monthly,
94`COUNT` or `UNTIL`. The UI exposes six choices, labelled against the
95event's own start date ("Every Wednesday", not "Weekly").
96
97**A rule outside the subset is stored verbatim, flagged `rrule_foreign`,
98shown on the event's page, exported unchanged, and not expanded.** An
99importer that eats a rule it does not understand eats data.
100
101### 3.2 The three orderings, each tested
102
103- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
104 Filtering first gives a four-occurrence series five.
105- **A named date a month does not have is NO occurrence.** The 31st is
106 skipped in February, never clamped. Same for 29 February yearly and
107 the fifth Friday.
108- **Expansion iterates in WALL CLOCK.** Stepping a UTC instant by 24
109 hours moves a 09:00 standup by an hour for half the year.
110
111Capped at `MaxOccurrencesPerSeries = 1000` per master per request.
112
113### 3.3 Overrides
114
115An override is a row with `series_id` set and `recurrence_id_local` =
116the occurrence's **ORIGINAL** local start. It is rendered at whatever
117time it now has.
118
119The range query is therefore two passes:
120
1211. Expand each master; substitute any override matching the generated
122 occurrence's original key.
1232. Add overrides whose own times land in the window but whose original
124 did not — the occurrence dragged in from last week.
125
126Skip pass 2 and a moved occurrence vanishes. Skip the dedupe and it
127appears twice. Both are tested.
128
129**"This and following" is a SPLIT**, not `RANGE=THISANDFUTURE`: almost
130nothing that reads our export supports the range modifier. The head gets
131an `UNTIL` the day before the cut, the tail is a new series with a new
132UID, and overrides after the cut move to the tail so none are orphaned.
133
134---
135
136## 4. Scriptless layout
137
138**The floor is the product.** Nothing here requires script.
139
140- **Month** is a `<table>`. A month IS tabular data; the table gives a
141 screen reader its headers for free. Six rows always, so the page does
142 not change height between months. **No spanning bars** — each day says
143 what is on it, and `+N more` links to the day.
144- **Day and week** are packed **on the server**: events clustered
145 transitively by overlap, then greedy lane assignment per cluster, then
146 percentages emitted as inline style (rastrillo's CSP permits inline
147 styles — its own partials carry them). Per cluster, not per day, so a
148 lone four o'clock is not squeezed by two meetings at nine.
149- **Click-a-time-to-create** is 48 half-hour `<a>` elements per day
150 column, each with an `rst-sr-only` name saying its time, each an
151 ordinary GET to a prefilled form.
152- **All-day events are not packed** into the column; giving a birthday a
153 height would make it look like a 24-hour meeting.
154- **320px**: month reflows to a stacked list; week keeps two dimensions
155 and scrolls.
156
157Enhancement, currently: rastrillo's own `datetime.js` on the date
158fields. Nothing else. **Deferred:** `create.js`, turning a pointer drag
159across empty slots into one navigation to `/events/new`. Trigger: Paul's
160first canary review says selecting a range by form is painful.
161
162---
163
164## 5. Reminders under hibernation
165
166A `time.Ticker` is not a scheduler; the process is not there when it
167fires.
168
169**One queue row per RULE**, holding only the next un-fired occurrence.
170A forever-daily standup costs one row; firing it computes the next.
171
172**Three ways a reminder fires**, and the redundancy is the design:
173
1741. `Options.NextDue` answers the activator's poll with
175 `MIN(due_at)`. The platform hibernates knowing when to wake, and
176 wakes the process for it. This is the precision path.
1772. The declared schedule, **every 6 hours**, POSTs `/jobs/reminders`.
178 Six and not five minutes: `CARLOS_HIBERNATE_IDLE` defaults to 15
179 minutes, so every tick buys a quarter hour of billed residency — a
180 5-minute schedule accrues ~730 instance-hours a month for seconds of
181 work.
1823. Any signed-in page view drains one opportunistically, bounded to one
183 per request.
184
185**The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
186refuses any POST carrying no `Sec-Fetch-Site`, `Origin` or `Referer`,
187and the scheduler sends none. Inside the protected router a tick 403s
188forever and the platform reads 4xx as "do not retry" — a silent,
189permanent failure. `TestTheTickRouteIsNotBehindCSRF` asserts WHICH
190refusal answered, and the deployed instance was checked from outside.
191
192**Work happens inside the request.** 202-plus-a-goroutine is a reliable
193way to be hibernated mid-send.
194
195**At-least-once**, and every ambiguous case resolves towards sending: a
196reminder that never arrives is worse than one that arrives twice. A
1975-minute claim lease bounds the duplicate window. `late_by_sec` is
198recorded and the message says how late it was.
199
200Undeliverable after 5 attempts, counted and surfaced on settings —
201retrying forever is a queue that never drains on a platform billed by
202residency.
203
204---
205
206## 6. ICS
207
208**Import never rejects a file.** It takes what it honestly can and
209reports the rest by name: events added, skipped, floating, foreign
210rules, dropped attendees, unresolvable zones. `VTIMEZONE` and `VALARM`
211blocks are skipped wholesale so their properties cannot land on the
212event being built — tested.
213
214**Export** emits IANA `TZID`s and **no `VTIMEZONE`**. Google, Apple and
215Thunderbird resolve zones from their own databases; Outlook desktop
216historically does not. Stated in the README. Trigger for building it: an
217Outlook import of our export shows a wrong time.
218
219Lines fold at **75 octets** on rune boundaries. The round-trip test
220found a real conformance bug on its first run: export wrote
221`20260902T0930` where the RFC's DATE-TIME is `YYYYMMDDTHHMMSS`, so the
222app produced files its own parser rejected. `compactLocal` was serving
223both URL occurrence keys and wire values, which is what let them
224disagree; `icsDateTime` is now separate.
225
226**Subscription feeds** are capability URLs outside the session, because
227Google and Apple will not sign in. Only the token's hash is stored, so
228the URL is shown once and the page says so. `ETag`/`304`, because on a
229platform billed by residency a 304 is the difference between a feed that
230costs a little and one that costs a lot. `poll_count` is shown on the
231share page: a feed is not free and the person deciding should see it.
232
233---
234
235## 7. What shipped, and what did not
236
237**Shipped and deployed:** sign-in, calendars and grants, events, five
238views, recurrence with occurrence editing and series splitting,
239reminders, ICS import/export/feeds, settings, theme switching.
240
241**Shipped since:** quick add — a natural-language box beside New event,
242with a live server-parsed detail pane and recorded (never invited)
243attendees. Its own spec is `2026-09-02-quick-add-design.md`.
244
245**Not built, each with a trigger:** attendees and guests, web push,
246external feed subscription, `VTIMEZONE`, expanding foreign rules, drag
247to move, skip-ahead expansion, sealed instants, search, background
248import, `RDATE` exactness, working-hours shading, free/busy across
249members, per-event colours, attachments, print, "find a time".
250
251**Known incomplete:**
252
253- The `rrule-go` oracle the brainstorm proposed as a test cross-check is
254 not wired. The golden vectors are hand-derived, which means they
255 encode my reading of the RFC rather than an independent one.
256- Locale catalogs are not written; user-visible strings are English
257 literals rather than catalog keys. This is a real divergence from the
258 suite's twelve-locale floor and is the largest single piece of debt in
259 the branch.
260- The chromedp browser drive is written and **still does not run in
261 this environment**: Chromium starts and sits at `about:blank` until
262 Go'''s 600-second timeout, with chromedp'''s goroutines blocked on
263 `select`. `TestBrowserWalk` is therefore still dead.
264
265 **The house rule is now met by a different gate.** `make browser`
266 runs `TestQuickAddInARealBrowser`, which drives Playwright — Go
267 starts the app and signs in, node drives the browser — and it
268 crosses from computed value to painted output twice: it measures the
269 popover'''s real `getBoundingClientRect()`, and it runs the whole
270 feature a second time **with JavaScript disabled** to prove the
271 scriptless floor rather than assert it.
272
273 It earned its place on its first run, catching a bug no Go assertion
274 could see: the popover rendered ~28px off the right-hand edge of a
275 1280px viewport, because it was anchored to the start edge while its
276 trigger sits at the far right of the header. Every string test
277 passed. Fixed by anchoring to the end edge.
278
279 Remaining work is porting `TestBrowserWalk` off chromedp, not
280 writing a gate.
281- UI copy has not been through the `copy-review` gate.
282- rastrillo's `mail.SMTP` ignores its context and sets no dial or
283 protocol deadline, so an abandoned send holds a goroutine and a socket
284 until the kernel closes it. Bounded to four in flight here by
285 `boundedSender`; fixed properly upstream.
286
287**Shipped since, again:** booking pages — a public "book time with me"
288link per host, with plain-text availability, a mailed link whose button
289confirms, a tokenised ICS, and booker-side reschedule and cancel. Its
290own spec is `2026-09-07-booking-design.md`, reviewed eight times by
291GPT-6 before code; the review found six defects in this engine's
292recurrence expansion that every view already had, all fixed on the
293same branch.
294
295### Home account connection — 2026-09-10
296
297The approved suite sign-in extension connects a verified Home identity to an
298existing Calendar person after fresh local verification and explicit confirmation.
299The Home subject never becomes an email address or creates a person, calendar,
300event or reminder. Each request resolves the stored person ID and checks current
301admission; calendar grants still decide which events that person can read or edit.
302Person IDs use SQLite AUTOINCREMENT and must never be reassigned or reused.
303
304Home is optional. Configure the CALENDAR_HOME_ISSUER, CALENDAR_HOME_CONSUMER_ID,
305CALENDAR_HOME_KEYS_BASE64 and CALENDAR_HOME_DEK_BASE64 values together. The final
306value is a dedicated 32-byte connection key, separate from event encryption.
307Partial configuration refuses startup. Without these values, local sign-in keeps
308its existing routes and behavior. The shared Home connection package supplies the
309proof flow and account screens; Calendar keeps its own page shell and grants.
310