| 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 |
| 5 | yet approved by Paul.** Both are required before this branch merges; |
| 6 | AGENTS.md §Review is the rule and this line is the record that it has |
| 7 | not happened. |
| 8 | |
| 9 | Raw 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.** |
| 14 | Recorded rather than hidden: the session built first and documented |
| 15 | second, and the cost is that the rulings below were made while writing |
| 16 | the code rather than before it. What follows is what was actually built, |
| 17 | not 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 | |
| 26 | Falsifiable: publish the database in full and the worst anyone learns is |
| 27 | the shape of the team's time and who is on the team — never what any |
| 28 | event is, where, or what was written about it. |
| 29 | |
| 30 | Enforced by `TestTheDatabaseNeverHoldsAPlaintextTitle`, which greps the |
| 31 | raw SQLite file and the WAL after a `wal_checkpoint(TRUNCATE)`. |
| 32 | |
| 33 | ### 1.1 Why times are plaintext |
| 34 | |
| 35 | Three reasons, and the third is the one that settles it: |
| 36 | |
| 37 | 1. Every view is a range query over `start_utc` / `end_utc`. |
| 38 | 2. `NextDue` reads `MIN(due_at)` at boot, before any request, so the |
| 39 | reminder queue cannot be behind a handle that opens per request. |
| 40 | 3. **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 | |
| 45 | The consequence is stated rather than buried: a published database gives |
| 46 | up the team's whole free/busy, which is a bigger leak than sheets' "size |
| 47 | of every sheet". AGENTS.md says so in those words. |
| 48 | |
| 49 | The deferred alternative — a sealed index the boot path can open before |
| 50 | any request — has a design and no implementation. Trigger: somebody |
| 51 | objects 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 |
| 58 | meant. The UTC columns are derived.** |
| 59 | |
| 60 | Rejected: storing the instant as truth. A government moves a DST |
| 61 | boundary, a release ships new tzdata, and a 09:00 standup is now at |
| 62 | 08:00 or 10:00 with nothing able to say what was meant. |
| 63 | |
| 64 | Columns: `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 |
| 69 | unix 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 |
| 77 | both took the pre-transition offset — true of the gap, false of the |
| 78 | fold. 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 |
| 93 | weekly, a single ordinal `BYDAY` or a single `BYMONTHDAY` for monthly, |
| 94 | `COUNT` or `UNTIL`. The UI exposes six choices, labelled against the |
| 95 | event's own start date ("Every Wednesday", not "Weekly"). |
| 96 | |
| 97 | **A rule outside the subset is stored verbatim, flagged `rrule_foreign`, |
| 98 | shown on the event's page, exported unchanged, and not expanded.** An |
| 99 | importer 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 | |
| 111 | Capped at `MaxOccurrencesPerSeries = 1000` per master per request. |
| 112 | |
| 113 | ### 3.3 Overrides |
| 114 | |
| 115 | An override is a row with `series_id` set and `recurrence_id_local` = |
| 116 | the occurrence's **ORIGINAL** local start. It is rendered at whatever |
| 117 | time it now has. |
| 118 | |
| 119 | The range query is therefore two passes: |
| 120 | |
| 121 | 1. Expand each master; substitute any override matching the generated |
| 122 | occurrence's original key. |
| 123 | 2. Add overrides whose own times land in the window but whose original |
| 124 | did not — the occurrence dragged in from last week. |
| 125 | |
| 126 | Skip pass 2 and a moved occurrence vanishes. Skip the dedupe and it |
| 127 | appears twice. Both are tested. |
| 128 | |
| 129 | **"This and following" is a SPLIT**, not `RANGE=THISANDFUTURE`: almost |
| 130 | nothing that reads our export supports the range modifier. The head gets |
| 131 | an `UNTIL` the day before the cut, the tail is a new series with a new |
| 132 | UID, 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 | |
| 157 | Enhancement, currently: rastrillo's own `datetime.js` on the date |
| 158 | fields. Nothing else. **Deferred:** `create.js`, turning a pointer drag |
| 159 | across empty slots into one navigation to `/events/new`. Trigger: Paul's |
| 160 | first canary review says selecting a range by form is painful. |
| 161 | |
| 162 | --- |
| 163 | |
| 164 | ## 5. Reminders under hibernation |
| 165 | |
| 166 | A `time.Ticker` is not a scheduler; the process is not there when it |
| 167 | fires. |
| 168 | |
| 169 | **One queue row per RULE**, holding only the next un-fired occurrence. |
| 170 | A 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 | |
| 174 | 1. `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. |
| 177 | 2. 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. |
| 182 | 3. 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` |
| 186 | refuses any POST carrying no `Sec-Fetch-Site`, `Origin` or `Referer`, |
| 187 | and the scheduler sends none. Inside the protected router a tick 403s |
| 188 | forever and the platform reads 4xx as "do not retry" — a silent, |
| 189 | permanent failure. `TestTheTickRouteIsNotBehindCSRF` asserts WHICH |
| 190 | refusal answered, and the deployed instance was checked from outside. |
| 191 | |
| 192 | **Work happens inside the request.** 202-plus-a-goroutine is a reliable |
| 193 | way to be hibernated mid-send. |
| 194 | |
| 195 | **At-least-once**, and every ambiguous case resolves towards sending: a |
| 196 | reminder that never arrives is worse than one that arrives twice. A |
| 197 | 5-minute claim lease bounds the duplicate window. `late_by_sec` is |
| 198 | recorded and the message says how late it was. |
| 199 | |
| 200 | Undeliverable after 5 attempts, counted and surfaced on settings — |
| 201 | retrying forever is a queue that never drains on a platform billed by |
| 202 | residency. |
| 203 | |
| 204 | --- |
| 205 | |
| 206 | ## 6. ICS |
| 207 | |
| 208 | **Import never rejects a file.** It takes what it honestly can and |
| 209 | reports the rest by name: events added, skipped, floating, foreign |
| 210 | rules, dropped attendees, unresolvable zones. `VTIMEZONE` and `VALARM` |
| 211 | blocks are skipped wholesale so their properties cannot land on the |
| 212 | event being built — tested. |
| 213 | |
| 214 | **Export** emits IANA `TZID`s and **no `VTIMEZONE`**. Google, Apple and |
| 215 | Thunderbird resolve zones from their own databases; Outlook desktop |
| 216 | historically does not. Stated in the README. Trigger for building it: an |
| 217 | Outlook import of our export shows a wrong time. |
| 218 | |
| 219 | Lines fold at **75 octets** on rune boundaries. The round-trip test |
| 220 | found a real conformance bug on its first run: export wrote |
| 221 | `20260902T0930` where the RFC's DATE-TIME is `YYYYMMDDTHHMMSS`, so the |
| 222 | app produced files its own parser rejected. `compactLocal` was serving |
| 223 | both URL occurrence keys and wire values, which is what let them |
| 224 | disagree; `icsDateTime` is now separate. |
| 225 | |
| 226 | **Subscription feeds** are capability URLs outside the session, because |
| 227 | Google and Apple will not sign in. Only the token's hash is stored, so |
| 228 | the URL is shown once and the page says so. `ETag`/`304`, because on a |
| 229 | platform billed by residency a 304 is the difference between a feed that |
| 230 | costs a little and one that costs a lot. `poll_count` is shown on the |
| 231 | share 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 |
| 238 | views, recurrence with occurrence editing and series splitting, |
| 239 | reminders, ICS import/export/feeds, settings, theme switching. |
| 240 | |
| 241 | **Shipped since:** quick add — a natural-language box beside New event, |
| 242 | with a live server-parsed detail pane and recorded (never invited) |
| 243 | attendees. Its own spec is `2026-09-02-quick-add-design.md`. |
| 244 | |
| 245 | **Not built, each with a trigger:** attendees and guests, web push, |
| 246 | external feed subscription, `VTIMEZONE`, expanding foreign rules, drag |
| 247 | to move, skip-ahead expansion, sealed instants, search, background |
| 248 | import, `RDATE` exactness, working-hours shading, free/busy across |
| 249 | members, 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" |
| 288 | link per host, with plain-text availability, a mailed link whose button |
| 289 | confirms, a tokenised ICS, and booker-side reschedule and cancel. Its |
| 290 | own spec is `2026-09-07-booking-design.md`, reviewed eight times by |
| 291 | GPT-6 before code; the review found six defects in this engine's |
| 292 | recurrence expansion that every view already had, all fixed on the |
| 293 | same branch. |
| 294 | |
| 295 | ### Home account connection — 2026-09-10 |
| 296 | |
| 297 | The approved suite sign-in extension connects a verified Home identity to an |
| 298 | existing Calendar person after fresh local verification and explicit confirmation. |
| 299 | The Home subject never becomes an email address or creates a person, calendar, |
| 300 | event or reminder. Each request resolves the stored person ID and checks current |
| 301 | admission; calendar grants still decide which events that person can read or edit. |
| 302 | Person IDs use SQLite AUTOINCREMENT and must never be reassigned or reused. |
| 303 | |
| 304 | Home is optional. Configure the CALENDAR_HOME_ISSUER, CALENDAR_HOME_CONSUMER_ID, |
| 305 | CALENDAR_HOME_KEYS_BASE64 and CALENDAR_HOME_DEK_BASE64 values together. The final |
| 306 | value is a dedicated 32-byte connection key, separate from event encryption. |
| 307 | Partial configuration refuses startup. Without these values, local sign-in keeps |
| 308 | its existing routes and behavior. The shared Home connection package supplies the |
| 309 | proof flow and account screens; Calendar keeps its own page shell and grants. |
| 310 | |