| 1 | # π€ Calendar β brainstorm |
| 2 | |
| 3 | **Status:** brainstorm, 2026-09-02. Raw material for the design spec, not the |
| 4 | spec. Every "ruled" below is a *proposal* for Paul; nothing here is settled |
| 5 | until it lands in the spec's Β§1 table with his name beside it. |
| 6 | **Scope:** the Calendar instance app, beside Sheets and Docs under Oficina. |
| 7 | Where a decision is already made over there, this document consumes it and |
| 8 | says so rather than restating it. |
| 9 | |
| 10 | The user's floor for tonight: **calendar layout, adding an event, reminders, |
| 11 | ICAL**, deployed. Everything else here is either argued into the overnight |
| 12 | cut with a reason, or deferred with a written trigger. |
| 13 | |
| 14 | --- |
| 15 | |
| 16 | ## 0. What was read, and the three facts that shaped this |
| 17 | |
| 18 | Read in full: `sheets-core/AGENTS.md`, `sheets-core/PRODUCT.md`, |
| 19 | `home/AGENTS.md`, `docs/AGENTS.md`, `estilo/AGENTS.md`, the Sheets design |
| 20 | spec (all sections), Docs Β§2, Β§6.2, Β§7.3, Β§14β15, the home spec Β§0β3, and |
| 21 | rastrillo v0.23.0's `SKILL.md`, `README.md`, `carlos/`, `mail/`, `serve.go` |
| 22 | and `seal` (Sheets' copy). |
| 23 | |
| 24 | Three verified facts do most of the work in this document: |
| 25 | |
| 26 | 1. **The platform owns the clock, and offers two handles.** |
| 27 | `rastrillo.Options.NextDue func() time.Time` answers the activator's |
| 28 | `GET /api/next-due` poll before hibernation β the instance is woken at |
| 29 | that instant. `carlos.ScheduleAt(ctx, name, at, path)` registers a |
| 30 | one-shot timer in the *box's* registry (survives restarts, upsert by |
| 31 | name, `^[a-z0-9][a-z0-9-]{0,31}$`, **400-day horizon, 1000 pending per |
| 32 | host**) and the platform POSTs to `path` when due. Ticks are |
| 33 | at-least-once, guarded by `carlos.Tick(r)`, and **the work must finish |
| 34 | inside the request** β 202-plus-goroutine hibernates mid-job. Off |
| 35 | platform both degrade to `ErrNotOnCarlos`. (`carlos/carlos.go`, |
| 36 | `carlos/schedule.go`, `serve.go:122β130`.) |
| 37 | 2. **The framework already ships the scriptless date input.** |
| 38 | `field-date`, `field-time`, `field-datetime`, `field-daterange` partials, |
| 39 | `form.Range(p, "starts", "ends")`, and `datetime.js` β a combobox reading |
| 40 | "next fri 9am" in the page's own locale, vocabulary from the catalog, |
| 41 | month names from `Intl`. Quick-add is mostly free. (`SKILL.md:185β195`.) |
| 42 | 3. **The home's assertion helper is still an open gap** (Sheets Β§15, Docs |
| 43 | Β§15.4). Docs signs in with a local magic link plus `$DOCS_ALLOWED_ADDRESSES`. |
| 44 | Calendar does the same tonight and migrates when the helper lands. |
| 45 | |
| 46 | --- |
| 47 | |
| 48 | ## 1. The one rule |
| 49 | |
| 50 | Three candidates, then the argument. |
| 51 | |
| 52 | ### Candidate A β sealed at rest, the Sheets shape |
| 53 | |
| 54 | > **An event's contents are sealed at rest. The bucket never holds a |
| 55 | > plaintext title, place, note or name.** |
| 56 | |
| 57 | The falsifiable form: **publish this database in full and the worst anyone |
| 58 | learns is the shape of the team's time β when they are busy, for how long, |
| 59 | how often, and in which time zones β and who is on the team. Never what any |
| 60 | event is, where it is, or what was written about it.** |
| 61 | |
| 62 | Test from the first commit: the plaintext grep over the raw SQLite file and |
| 63 | WAL, seeded with a fixture calendar whose titles, locations and notes are |
| 64 | distinctive strings. `plaintextExempt` starts empty. |
| 65 | |
| 66 | ### Candidate B β store what the person said |
| 67 | |
| 68 | > **Every event is stored as the wall-clock time the person typed and the |
| 69 | > zone they typed it in. The instant is derived, never authored.** |
| 70 | |
| 71 | Falsifiable: change the binary's embedded tzdata, run the re-derive sweep, |
| 72 | and every event still reads the same wall-clock in its own zone. A test |
| 73 | builds with a doctored zone, re-derives, and asserts `start_local` and `tz` |
| 74 | are byte-identical while `start_utc` moved. |
| 75 | |
| 76 | ### Candidate C β a reminder fires once and never silently late |
| 77 | |
| 78 | > **A reminder is delivered at least once, at most twice, and its lateness |
| 79 | > is visible.** |
| 80 | |
| 81 | Falsifiable: kill the process at every line between claim and mark; restart; |
| 82 | the occurrence is delivered, the duplicate (if any) carries the same |
| 83 | `Message-ID`, and the queue row records how late it was. |
| 84 | |
| 85 | ### The argument |
| 86 | |
| 87 | **A is the one rule.** It is the family's shape β Sheets, Docs and the home |
| 88 | all open with a storage rule with a `publish the bucket andβ¦` test β and it |
| 89 | is the only one of the three that constrains *every* table from the first |
| 90 | migration. B is the most important *correctness* decision in the app and |
| 91 | belongs in the settled-decisions table as ruling 1, with its test as a |
| 92 | gate. C is a testing gate, not a rule; it constrains one subsystem. |
| 93 | |
| 94 | **A is also harder to keep than Sheets' version, and the spec must say so.** |
| 95 | A spreadsheet's plaintext shape β coordinates, sizes, edit timestamps β is |
| 96 | nearly meaningless. A calendar's plaintext shape is *free/busy*, which |
| 97 | companies sell as a product. "Busy every Tuesday 14:00β15:00, recurring |
| 98 | weekly, reminder one day before" is a lot to know about a person. The |
| 99 | honest leak list (Β§4.4) says this in words, and the README does too. |
| 100 | |
| 101 | **Why times stay plaintext anyway, tonight.** Three reasons, and the third |
| 102 | is the one that ends the argument: |
| 103 | |
| 104 | - Every view is a range query. Month view is `start_utc < ? AND end_utc > |
| 105 | ?`; sealing instants means decrypting every row per page or holding a |
| 106 | decrypted index in memory. |
| 107 | - `NextDue()` must answer from the database at boot, before any handle is |
| 108 | open, with `SELECT MIN(due_at) β¦ WHERE fired_at IS NULL`. |
| 109 | - **The platform's timer registry holds every reminder's fire instant in |
| 110 | plaintext regardless** (`ScheduleAt` sends `at` over the control socket |
| 111 | and the box keeps it). Sealing instants in our file while they sit |
| 112 | readable in the box's registry would be claiming a property the estate |
| 113 | does not have. |
| 114 | |
| 115 | So the rule forbids *content* at rest and admits *shape*, and says which is |
| 116 | which. A sealed-instants design with an in-memory index and a `NextDue` |
| 117 | that does not need plaintext is in the deferred table (Β§7) with a real |
| 118 | trigger, because at dogfood N it is feasible and someone will ask. |
| 119 | |
| 120 | --- |
| 121 | |
| 122 | ## 2. Recurrence |
| 123 | |
| 124 | RRULE is a swamp because RFC 5545 Β§3.3.10 is a grammar for every rule |
| 125 | anyone could want, and calendars need the six rules anyone ever uses. |
| 126 | |
| 127 | ### 2.1 The subset that ships β and it is Google's UI, not a compromise |
| 128 | |
| 129 | Google Calendar's own repeat picker offers exactly: daily, weekly on chosen |
| 130 | days, monthly on day N, monthly on the Nth weekday, yearly, and "custom" |
| 131 | with an interval and an end. That is the subset: |
| 132 | |
| 133 | | Ships | Form | |
| 134 | |---|---| |
| 135 | | `FREQ=DAILY\|WEEKLY\|MONTHLY\|YEARLY` | β | |
| 136 | | `INTERVAL=n` | every 2 weeks, every 3 months | |
| 137 | | `BYDAY` for WEEKLY | `MO,WE,FR` | |
| 138 | | `BYDAY` with one ordinal for MONTHLY | `2TU` (second Tuesday), `-1FR` (last Friday) | |
| 139 | | `BYMONTHDAY` single value for MONTHLY | `15`, `-1` (last day) | |
| 140 | | `COUNT=n` or `UNTIL=β¦`, or neither | forever is allowed | |
| 141 | | `WKST` | honoured on import, `MO` on export | |
| 142 | |
| 143 | **Not in the subset:** `BYSETPOS`, `BYYEARDAY`, `BYWEEKNO`, `BYHOUR` and |
| 144 | below, `HOURLY`/`MINUTELY`/`SECONDLY`, multiple `BYDAY` on MONTHLY, |
| 145 | multiple `BYMONTH`, `RDATE` lists as a first-class thing. |
| 146 | |
| 147 | **Hand-roll the expander, ~250 lines, and use `teambition/rrule-go` as a |
| 148 | test oracle only.** The expander's contract with our zone model (Β§3) is |
| 149 | the thing that must be ours: it iterates in *wall-clock* in the event's |
| 150 | zone and resolves each occurrence to an instant afterwards, so a 09:00 |
| 151 | weekly meeting stays 09:00 across a DST change. A runtime dependency would |
| 152 | be doing the same arithmetic one abstraction away from our own rules. The |
| 153 | oracle catches the cases where I am wrong about the RFC: MONTHLY on the |
| 154 | 31st (skip months without one β RFC says "MUST be ignored", not "clamp"), |
| 155 | `-1FR` in a five-Friday month, `COUNT` interacting with `EXDATE` (COUNT |
| 156 | counts generated instances *before* exclusion), Feb 29 yearly (skipped in |
| 157 | non-leap years per RFC; Google shows Feb 28 and we do not β say so in the |
| 158 | UI copy for that one case). |
| 159 | |
| 160 | **A foreign rule β one outside the subset β is imported, never refused.** |
| 161 | It is stored verbatim in `rrule`, flagged `rrule_foreign=1`, the master |
| 162 | renders once at its `DTSTART` with a marker "repeats on a rule Calendar |
| 163 | cannot show yet", exports back byte-identical, and the series editor |
| 164 | offers only "replace this rule" (with the subset picker). The count of |
| 165 | foreign rules is logged on import; the rules themselves are not (a rule is |
| 166 | structure, not content, but the line stays clean). Deferred: expanding |
| 167 | foreign rules (Β§7). |
| 168 | |
| 169 | ### 2.2 Exceptions and overrides β the RFC model, because it round-trips |
| 170 | |
| 171 | Three kinds of row in one `events` table: |
| 172 | |
| 173 | - **Single event** β `rrule=''`, `series_id NULL`. |
| 174 | - **Series master** β `rrule!=''`, `series_id NULL`. Carries `exdates` |
| 175 | (deleted occurrences, as local wall-clock datetimes) and `until_utc`. |
| 176 | - **Override** β `series_id = master.id`, `recurrence_id_local = the |
| 177 | original occurrence's wall-clock`, and a full copy of every editable |
| 178 | field. This is RFC 5545 `RECURRENCE-ID` exactly, so an override imports |
| 179 | and exports without translation. |
| 180 | |
| 181 | Deletion of one occurrence is an `EXDATE` on the master, **not a tombstone |
| 182 | row** β a deletion is not content, the column is one line, and it exports |
| 183 | exactly. Modification of one occurrence is an override row. An override |
| 184 | that has no matching generated occurrence (an imported `RDATE`, or an |
| 185 | occurrence the rule stopped generating after a rule edit) is still shown β |
| 186 | it is an addition, and the recurrence-set algorithm below treats it as one. |
| 187 | |
| 188 | **"This and following" is a split, not a `RANGE=THISANDFUTURE`.** The |
| 189 | master gets `UNTIL = the occurrence before`, a new master is created with |
| 190 | `DTSTART = this occurrence` and the new values, and every override whose |
| 191 | `recurrence_id β₯ split point` is re-parented in the same transaction. The |
| 192 | tail gets a new UID. That is what Google and Apple do; `RANGE=THISANDFUTURE` |
| 193 | is in the RFC and in almost no shipping implementation, and an export |
| 194 | carrying it would be mis-read by the tools people actually use. |
| 195 | |
| 196 | **Editing "all" of a series with overrides:** the master's fields change; |
| 197 | overrides keep their own values (that is what an override means); the |
| 198 | question "also reset the N exceptions?" is asked, defaulting to no. |
| 199 | |
| 200 | **Deleting a series deletes its overrides in the same transaction.** Named |
| 201 | because the orphan is the classic bug (Β§9.13). |
| 202 | |
| 203 | ### 2.3 Expanding for a view without materialising infinity |
| 204 | |
| 205 | Occurrences are **never rows**. They are computed per request, inside a |
| 206 | window `[view_start, view_end)` in the viewer's zone: |
| 207 | |
| 208 | 1. **Timed singles and overrides** by their *actual* time: |
| 209 | `start_utc < view_end AND end_utc > view_start`. |
| 210 | 2. **All-day singles and overrides** by date text: |
| 211 | `start_local < 'view_end_date' AND end_local > 'view_start_date'` |
| 212 | (ISO dates compare lexically, and an all-day event has no instant). |
| 213 | 3. **Masters that could intersect:** `dtstart_utc < view_end AND (until_utc |
| 214 | IS NULL OR until_utc >= view_start)`. For each, the expander iterates |
| 215 | from `DTSTART`, discarding occurrences before the window, until it |
| 216 | passes the window or hits `COUNT`/`UNTIL`. A daily series ten years old |
| 217 | is ~3,650 cheap iterations; fine at dogfood N. A **hard cap of 1,000 |
| 218 | generated occurrences per master per request** is the guard against a |
| 219 | hostile or broken rule; past it the series renders a marker and stops. |
| 220 | 4. **The recurrence set** = generated β `exdates` β occurrences that have an |
| 221 | override (matched on `recurrence_id_local`) + overrides from steps 1β2 |
| 222 | (matched on their *new* time). Overrides are queried by where they |
| 223 | *moved to*, never by where they came from β querying by |
| 224 | `recurrence_id` misses an occurrence dragged into the window from |
| 225 | outside it, which is the second classic bug. |
| 226 | 5. **Occurrence duration is a wall-clock delta.** `end_local β start_local` |
| 227 | as a calendar difference, applied to each occurrence's wall-clock start |
| 228 | β so a 09:00β10:00 meeting is 09:00β10:00 on both sides of a DST change, |
| 229 | which is what "till ten" means to a person. |
| 230 | |
| 231 | **An occurrence's address is `/events/{id}/{yyyymmddThhmm}`** β the master |
| 232 | id plus the *original* occurrence's wall-clock. That URL is how a scriptless |
| 233 | page opens "this Tuesday's standup"; the edit form on it carries a |
| 234 | `scope=this|following|all` radio. |
| 235 | |
| 236 | Skip-ahead arithmetic (first occurrence β₯ window start in O(1) for |
| 237 | DAILY/WEEKLY) is deferred with a measured trigger (Β§7). |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ## 3. Time zones |
| 242 | |
| 243 | This is where calendars die, and the reason is always the same: the app |
| 244 | stored the answer (an instant) and threw away the question (what the person |
| 245 | meant). |
| 246 | |
| 247 | ### 3.1 Ruling 1 β store what the person said |
| 248 | |
| 249 | Every event row stores: |
| 250 | |
| 251 | - `start_local`, `end_local` β ISO 8601 wall-clock to the minute, no |
| 252 | offset: `2026-09-02T09:00`. For all-day events, dates: `2026-09-02`, end |
| 253 | **exclusive** (the RFC's rule; the UI shows inclusive and the conversion |
| 254 | happens in exactly one function with a test). |
| 255 | - `tz` β an IANA name (`Europe/Dublin`), `''` for floating, ignored for |
| 256 | all-day. |
| 257 | - `all_day` β the third kind, not a midnight-to-midnight timed event. |
| 258 | - `start_utc`, `end_utc` β **derived** unix seconds, for indexes and range |
| 259 | queries only. Never edited, never trusted as intent. |
| 260 | |
| 261 | Go embeds `time/tzdata`; the binary stamps its tzdata version into a `meta` |
| 262 | row at boot. If the stamp differs from the last boot, a **re-derive sweep** |
| 263 | recomputes every `*_utc` column (a few thousand rows; log the count, never |
| 264 | a title), re-queues every pending reminder (Β§5), and re-asserts the |
| 265 | platform timer. A zone abolishing DST next year moves the instant of every |
| 266 | future 09:00 meeting there and leaves `start_local` untouched, which is |
| 267 | correct and is the whole point. |
| 268 | |
| 269 | ### 3.2 The three kinds, and which the UI can make |
| 270 | |
| 271 | | Kind | RFC form | Stored | Rendered | UI can create? | |
| 272 | |---|---|---|---|---| |
| 273 | | **All-day** | `DTSTART;VALUE=DATE` | dates, no zone | on that date in every viewer's zone | yes | |
| 274 | | **Zoned** | `DTSTART;TZID=β¦` | wall-clock + IANA zone | converted to the viewer's zone | yes β the default | |
| 275 | | **Floating** | `DTSTART` bare | wall-clock, `tz=''` | at that wall-clock in *whatever* zone the viewer is in | **no** β import and export only | |
| 276 | |
| 277 | Floating is refused in the UI on purpose. On a *shared* calendar a floating |
| 278 | 09:00 is read as the same instant by two people in two zones, and it is |
| 279 | not. It exists in the model because the RFC requires it and files contain |
| 280 | it; a floating event renders with a marker ("floating β 09:00 wherever you |
| 281 | are") and its edit form offers to pin it to a zone. |
| 282 | |
| 283 | ### 3.3 The viewer's zone is stated, never sniffed |
| 284 | |
| 285 | Scriptless has no `Intl.DateTimeFormat().resolvedOptions().timeZone`, and |
| 286 | that is a feature. A person's zone is a **preference on their member row**, |
| 287 | defaulted from the instance's team zone at first sign-in, changed in |
| 288 | settings. The one enhancement `datetime.js` may add is a *notice* β "your |
| 289 | browser says America/New_York; you are set to Europe/Dublin. Switch?" β with |
| 290 | a real form behind it. Never a silent rewrite. The "my whole calendar |
| 291 | shifted eight hours when I landed" horror is a sniff. |
| 292 | |
| 293 | The team zone is `$CALENDAR_TZ` at provisioning, editable in settings. It |
| 294 | is used for ICS `X-WR-TIMEZONE` and as the member default, nothing else. |
| 295 | |
| 296 | The zone is per person, not per team, so it belongs with the home's display |
| 297 | preferences eventually β it fails cosmetically under a hostile value, which |
| 298 | is the home spec's test for a claim riding an assertion. Deferred with the |
| 299 | same trigger as theme (Β§7). |
| 300 | |
| 301 | ### 3.4 Rendering across zones |
| 302 | |
| 303 | - **The primary time is always the viewer's.** An event authored in Dublin |
| 304 | by a Dubliner and viewed from Lisbon shows at 08:00, with "09:00 Dublin" |
| 305 | as secondary text β only when the zones differ. Never a `(GMT+1)` suffix |
| 306 | on everything. |
| 307 | - **An event that crosses the viewer's midnight is split into two chips** |
| 308 | in day and week grids, server-side, with continuation markers `β` and |
| 309 | `β` that carry `aria-label` text. The event's own date is irrelevant to |
| 310 | where it renders; the viewer's date governs. |
| 311 | - **"Today" is computed in the viewer's zone.** A server in UTC deciding |
| 312 | what "today" is for someone in Auckland is bug Β§9.16. |
| 313 | |
| 314 | ### 3.5 Gaps and folds |
| 315 | |
| 316 | 01:30 on a spring-forward day does not exist; on a fall-back day it exists |
| 317 | twice. `time.Date` in the location normalises; Go documents that the choice |
| 318 | "is not guaranteed". We accept Go's choice, **pin it with a test so it is |
| 319 | known rather than assumed**, and do not pretend to do better. It affects one |
| 320 | hour a year between 01:00 and 03:00. |
| 321 | |
| 322 | `UNTIL` must be UTC when `DTSTART` is zoned (RFC), and half the exporters in |
| 323 | the wild write it local. **Lenient on import** (a bare `UNTIL` is read in |
| 324 | the event's zone), **strict on export** (always `Z`). |
| 325 | |
| 326 | --- |
| 327 | |
| 328 | ## 4. Data model |
| 329 | |
| 330 | ### 4.1 Tables |
| 331 | |
| 332 | ```go |
| 333 | // Member β who may hold a session. Address plaintext by the home spec's |
| 334 | // ruling 5 (the sharer typed it; the team already knows it). |
| 335 | type Member struct { |
| 336 | ID uint64 `gorm:"primaryKey"` |
| 337 | Address string `gorm:"uniqueIndex"` |
| 338 | PersonRef string // from the home, once the contract is consumed; '' until then |
| 339 | NameSealed []byte // seal purpose "member.name" |
| 340 | TZ string // IANA; defaults to the team zone |
| 341 | WeekStart int // 1 = Monday (default), 7 = Sunday, 6 = Saturday |
| 342 | DefaultReminderMin int // applied to timed events they create; 10 |
| 343 | DefaultAllDayMin int // applied to all-day events; 15*60 (09:00 the day before) |
| 344 | CreatedAt time.Time |
| 345 | } |
| 346 | |
| 347 | // Calendar β a container. One "team" calendar per instance, personal ones |
| 348 | // per member. Colour is an OKLCH intent, as Sheets' fills are. |
| 349 | type Calendar struct { |
| 350 | ID uint64 `gorm:"primaryKey"` |
| 351 | NameSealed []byte // seal purpose "calendar.name" |
| 352 | Kind string // "team" | "personal" |
| 353 | OwnerID uint64 |
| 354 | HueIntent float64 |
| 355 | ChromaIntent float64 |
| 356 | CreatedAt time.Time |
| 357 | ArchivedAt *time.Time `gorm:"index"` |
| 358 | } |
| 359 | |
| 360 | // Grant β the hand-written grants join. Explicit rows even for the team |
| 361 | // calendar (every member is granted editor on it at creation), so one |
| 362 | // query shape serves every calendar and 404-not-403 has one home. |
| 363 | type Grant struct { |
| 364 | ID uint64 `gorm:"primaryKey"` |
| 365 | CalendarID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"` |
| 366 | MemberID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"` |
| 367 | Role string // "owner" | "editor" | "viewer" |
| 368 | } |
| 369 | |
| 370 | // Event β single, series master, or override (see Β§2.2). |
| 371 | type Event struct { |
| 372 | ID uint64 `gorm:"primaryKey"` |
| 373 | CalendarID uint64 `gorm:"index"` |
| 374 | |
| 375 | // Identity for ICS. The hash is the lookup key (re-import dedupes on |
| 376 | // it); the sealed copy is what exports. UIDs are sealed because some |
| 377 | // generators build them from the title. |
| 378 | UIDHash string `gorm:"index"` |
| 379 | UIDSealed []byte // seal purpose "event.uid" |
| 380 | |
| 381 | // Series linkage. An override has both; a master has neither. |
| 382 | SeriesID *uint64 `gorm:"index"` |
| 383 | RecurrenceIDLocal string // "2026-09-02T09:00" β the ORIGINAL occurrence |
| 384 | |
| 385 | TitleSealed []byte // "event.title" |
| 386 | LocationSealed []byte // "event.location" |
| 387 | NotesSealed []byte // "event.notes" |
| 388 | |
| 389 | // Intent β ruling 1. |
| 390 | AllDay bool |
| 391 | StartLocal string // "2026-09-02T09:00" or "2026-09-02" |
| 392 | EndLocal string // exclusive for all-day |
| 393 | TZ string // IANA; "" = floating; ignored when AllDay |
| 394 | |
| 395 | // Derived, index-only. Rebuilt by the re-derive sweep. Plaintext. |
| 396 | StartUTC int64 `gorm:"index"` |
| 397 | EndUTC int64 `gorm:"index"` |
| 398 | |
| 399 | // Recurrence β plaintext structure, published in the README. |
| 400 | RRule string // "" for singles and overrides |
| 401 | RRuleForeign bool |
| 402 | UntilUTC *int64 `gorm:"index"` |
| 403 | Exdates string // comma-separated local datetimes |
| 404 | |
| 405 | Kind string // "meeting" | "commitment" | "note" β Β§8.2 |
| 406 | Status string // "confirmed" | "tentative" | "cancelled" |
| 407 | Sequence int // RFC SEQUENCE; bumped on every edit |
| 408 | |
| 409 | CreatedBy uint64 |
| 410 | CreatedAt time.Time |
| 411 | UpdatedAt time.Time // exported as DTSTAMP / LAST-MODIFIED |
| 412 | DeletedAt *time.Time `gorm:"index"` |
| 413 | } |
| 414 | |
| 415 | // Reminder β the RULE, and it is per PERSON (Β§5.3). "Remind me 10 minutes |
| 416 | // before" is a fact about me, not about the event. |
| 417 | type Reminder struct { |
| 418 | ID uint64 `gorm:"primaryKey"` |
| 419 | EventID uint64 `gorm:"index"` |
| 420 | MemberID uint64 `gorm:"index"` |
| 421 | OffsetMin int // before start; negative = after |
| 422 | Channel string // "email" tonight |
| 423 | } |
| 424 | |
| 425 | // ReminderQueue β the NEXT un-fired occurrence per rule, and only that. |
| 426 | // Bounded by the number of rules, not by the length of any series. |
| 427 | type ReminderQueue struct { |
| 428 | ID uint64 `gorm:"primaryKey"` |
| 429 | ReminderID uint64 `gorm:"uniqueIndex"` // at most one pending row per rule |
| 430 | OccurrenceKey string // "{event_id}/{recurrence_id_local}" β the idempotency key |
| 431 | DueAt int64 `gorm:"index"` |
| 432 | ClaimedAt *int64 |
| 433 | FiredAt *int64 |
| 434 | Attempts int |
| 435 | LateBySec int64 // recorded at fire; the honesty column |
| 436 | } |
| 437 | |
| 438 | // Feed β a subscription capability. Token hashed, per the share-link |
| 439 | // ruling. Per person: it exports that person's calendars and reminders. |
| 440 | type Feed struct { |
| 441 | ID uint64 `gorm:"primaryKey"` |
| 442 | MemberID uint64 `gorm:"index"` |
| 443 | CalendarID uint64 `gorm:"index"` |
| 444 | TokenHash string `gorm:"uniqueIndex"` |
| 445 | CreatedAt time.Time |
| 446 | RevokedAt *time.Time |
| 447 | LastPolledAt *time.Time |
| 448 | PollCount int64 |
| 449 | } |
| 450 | |
| 451 | // Meta β key/value. "tzdata_version", "schema". |
| 452 | type Meta struct { |
| 453 | Key string `gorm:"primaryKey"` |
| 454 | Value string |
| 455 | } |
| 456 | ``` |
| 457 | |
| 458 | **Not created tonight, designed so its arrival is additive:** `attendees` |
| 459 | (`event_id`, `member_id NULL`, `address_hash`, `address_sealed`, |
| 460 | `name_sealed`, `partstat`, `role`). Tonight's import counts `ATTENDEE` lines |
| 461 | and says how many were dropped. |
| 462 | |
| 463 | ### 4.2 Seal purposes |
| 464 | |
| 465 | `oficina/calendar:` + `event.title`, `event.location`, `event.notes`, |
| 466 | `event.uid`, `calendar.name`, `member.name`. Lifted from Sheets' `seal` |
| 467 | package unchanged except for the label prefix; golden vectors re-pinned. |
| 468 | |
| 469 | ### 4.3 Sealed versus plaintext, with the reason for each |
| 470 | |
| 471 | | Column | At rest | Why | |
| 472 | |---|---|---| |
| 473 | | title, location, notes | sealed | content | |
| 474 | | calendar name, member display name | sealed | content (Sheets seals titles and tab names) | |
| 475 | | UID | sealed + SHA-256 | some generators derive it from the title; the hash is the re-import key | |
| 476 | | member address | **plaintext** | home spec ruling 5; reminder email needs it; the team typed it | |
| 477 | | `start_local`, `end_local`, `tz`, `*_utc`, `until_utc`, `exdates` | **plaintext** | every view is a range query; `NextDue` reads it at boot; the platform holds the instants anyway (Β§1) | |
| 478 | | `rrule` | **plaintext** | structure, not prose β the same class as a format record; reveals nothing the instants don't | |
| 479 | | kind, status, sequence, colours, ids, timestamps | plaintext | shape | |
| 480 | | reminder offsets, `due_at`, `occurrence_key` | plaintext | the queue is what `NextDue` reads | |
| 481 | | feed token | hashed | a stored plaintext token is a live read capability in the bucket | |
| 482 | |
| 483 | ### 4.4 The leak list, stated once and never contradicted |
| 484 | |
| 485 | Publish the database and you learn: |
| 486 | |
| 487 | 1. **The team's free/busy in full**, past and future, including recurrence |
| 488 | patterns and which zone each event was authored in. This is a bigger |
| 489 | leak than Sheets' "shape and size of every sheet", and the README says |
| 490 | so in those words. |
| 491 | 2. Who is on the team, by readable address, and who owns which calendar. |
| 492 | 3. Who set reminders how far ahead, and when the next one fires. |
| 493 | 4. That some events are cancelled or tentative, and how many are meetings |
| 494 | versus commitments versus notes. |
| 495 | |
| 496 | You learn **no title, place or note**, and no name of anyone outside the |
| 497 | team (there are none yet β attendees are deferred). |
| 498 | |
| 499 | Three leaks *outside* the database, listed because a rule that only covers |
| 500 | the bucket invites reading them as covered: |
| 501 | |
| 502 | 5. **A reminder email carries the title** through the mail relay and into |
| 503 | the recipient's mailbox. That is delivery, not storage, and it is the |
| 504 | product working β but the relay's logs are not ours. |
| 505 | 6. **A subscription feed puts the calendar's plaintext in whatever the |
| 506 | subscriber pointed at it** β Google, Apple, a phone. The token is in a |
| 507 | URL in someone else's account (Β§6.4). |
| 508 | 7. **The platform's timer registry holds reminder instants** (Β§1). |
| 509 | |
| 510 | A compromised live host reads everything, as in Sheets. **We never claim |
| 511 | "we can't read your data."** |
| 512 | |
| 513 | --- |
| 514 | |
| 515 | ## 5. Reminders |
| 516 | |
| 517 | home/AGENTS.md: *"A dormant instance runs no timers⦠This shipped twice in |
| 518 | one spec before it was caught."* So the design starts from the premise that |
| 519 | the process is not there when the reminder is due. |
| 520 | |
| 521 | ### 5.1 One queue, one due-time, two ways to be woken |
| 522 | |
| 523 | - `reminders` is the **rule** ("10 minutes before, by email, for me"). |
| 524 | - `reminder_queue` holds **exactly one pending row per rule**: the next |
| 525 | un-fired occurrence, computed by the expander (first occurrence whose |
| 526 | `start β offset > now`, honouring `exdates` and overrides β an override's |
| 527 | *moved* start governs its reminder). When a row fires, the rule's next |
| 528 | occurrence is computed and enqueued. The queue is bounded by the number |
| 529 | of rules, so a forever-daily standup costs one row, not infinity. |
| 530 | - `NextDue()` is `SELECT MIN(due_at) FROM reminder_queue WHERE fired_at IS |
| 531 | NULL`. The activator polls it before hibernating and wakes us then. |
| 532 | - **Belt and braces: one named platform timer.** On every queue mutation, |
| 533 | `carlos.ScheduleAt(ctx, "reminders", nextDue, "/jobs/reminders")` β |
| 534 | upsert by name, so it is always the single next instant. One timer per |
| 535 | instance, never one per reminder, because the host cap is 1,000 shared |
| 536 | across every app on the box and a per-occurrence timer would need the |
| 537 | materialised occurrences Β§2.3 refuses. A due time past the 400-day |
| 538 | horizon is not registered; `NextDue` still answers it, and the next |
| 539 | mutation inside the horizon re-asserts. |
| 540 | - **The drain runs on boot, on every tick, and opportunistically on any |
| 541 | request** when `MIN(due_at) β€ now` (checked at most once a minute via an |
| 542 | in-memory stamp). Three entrances, one function, all idempotent. Boot |
| 543 | covers the activator's wake; the tick covers the registry timer; the |
| 544 | request path covers a team that is simply using the app while a reminder |
| 545 | comes due. |
| 546 | - **The drain works inside the request and returns 204.** Fifty SMTP sends |
| 547 | at 200 ms is ten seconds, inside the platform's timeout. Past a hundred |
| 548 | due rows it drains a page, re-asserts the timer for *now*, and returns β |
| 549 | the platform re-ticks and it continues. 5xx on a mailer failure so the |
| 550 | platform retries; the queue rows are the real retry state. |
| 551 | |
| 552 | Off platform (`ErrNotOnCarlos`), the request-path drain is the only |
| 553 | entrance and `run-dev.sh` says so. Tests fake the control socket. |
| 554 | |
| 555 | ### 5.2 Idempotency β at-least-once with a claim, and a deterministic Message-ID |
| 556 | |
| 557 | Which is worse for a reminder: fired twice or never fired? Never. A |
| 558 | reminder that does not fire is the product failing at its one job; a |
| 559 | duplicate is a nuisance. So: |
| 560 | |
| 561 | 1. In a transaction, select due rows `WHERE fired_at IS NULL AND (claimed_at |
| 562 | IS NULL OR claimed_at < now β 5 min)` and set `claimed_at = now, |
| 563 | attempts += 1`. The claim is the lease. |
| 564 | 2. Send, **with `Message-ID: <{reminder_id}.{occurrence_key_hash}@{host}>`** |
| 565 | β deterministic per occurrence. A crash between send and mark yields a |
| 566 | second send with the same Message-ID, which every mail client threads |
| 567 | or drops as a duplicate. That is the entire duplicate story, and it |
| 568 | costs one header. |
| 569 | 3. Set `fired_at`, record `late_by_sec = now β due_at`, enqueue the rule's |
| 570 | next occurrence, re-assert the timer. |
| 571 | |
| 572 | A row that reaches five attempts stops and shows on the person's settings |
| 573 | page as "could not deliver" with the occurrence β visible failure, never |
| 574 | silent. `carlos.TickOccurrence` is not our dedupe key (the queue rows are), |
| 575 | but the handler reads it and skips an occurrence it has already drained, so |
| 576 | a platform redelivery is a no-op rather than a second scan. |
| 577 | |
| 578 | **Lateness is visible.** The Today page shows a reminder that fired more |
| 579 | than ten minutes late as "reminded late (instance was asleep)". The house |
| 580 | rule is to say the true thing; an instance that hibernates through a |
| 581 | reminder should say so rather than let the person conclude they missed an |
| 582 | email. |
| 583 | |
| 584 | ### 5.3 Reminders are per person |
| 585 | |
| 586 | Every calendar gets this wrong because ICS `VALARM` lives on the event, and |
| 587 | the event is shared. On a team calendar, "remind me" is about *me*. So a |
| 588 | reminder row has a `member_id`, defaults come from the member's settings |
| 589 | and are applied to the creator at event creation, and each other member can |
| 590 | add their own from the event page. Import attaches `VALARM`s to the |
| 591 | importing member; export emits only the exporting member's. |
| 592 | |
| 593 | Who is reminded of what, by default: the creator, of what they create. |
| 594 | Nobody else, until attendees exist (Β§7). A team calendar where everyone is |
| 595 | reminded of everything is a team calendar everyone mutes. |
| 596 | |
| 597 | ### 5.4 Channel |
| 598 | |
| 599 | **Email, tonight, plain text, one line of subject.** `rastrillo/mail` is the |
| 600 | one outbound surface; a production instance with no mailer refuses to boot |
| 601 | (Docs' fail-closed shape). Subject: "Standup in 10 minutes". Body: the time |
| 602 | in the recipient's zone, the location, the link. No HTML. |
| 603 | |
| 604 | Free with it: the **Today page lists reminders due in the next hour** as a |
| 605 | callout β a scriptless in-app reminder that costs nothing. Web push |
| 606 | (service worker, VAPID, script-only by nature) is deferred with a trigger |
| 607 | (Β§7). |
| 608 | |
| 609 | --- |
| 610 | |
| 611 | ## 6. ICAL |
| 612 | |
| 613 | ### 6.1 Import |
| 614 | |
| 615 | `POST /import` with a file, **synchronous**, 10 MB cap, parsed in the |
| 616 | request, nothing persisted but the events. Sheets runs XLSX import as a |
| 617 | background job with a status page; a 5 MB `.ics` parses in well under a |
| 618 | second, the rastrillo `jobs` package is in-memory and a deploy ends it, and |
| 619 | a blob store is a whole surface (Β§2.3 of Sheets) to add for a file we do not |
| 620 | need to keep. A large-file job is deferred with a trigger (Β§7). |
| 621 | |
| 622 | Sharp edges, each with a decision: |
| 623 | |
| 624 | - **`TZID` β IANA.** Three tiers: an IANA name verbatim; a Windows name |
| 625 | (`W. Europe Standard Time`) via an embedded CLDR `windowsZones` table |
| 626 | (~140 rows); a Mozilla/Outlook path (`/mozilla.org/β¦/Europe/Dublin`) by |
| 627 | extracting the trailing IANA segment. Anything else: **imported as |
| 628 | floating with a per-file notice** β "N events had a time zone Calendar |
| 629 | did not recognise and were imported as floating; open one to pin it". |
| 630 | The `VTIMEZONE` blocks themselves are not used to *resolve* times; our |
| 631 | tzdata is the authority, and a file's `VTIMEZONE` is routinely years |
| 632 | stale. |
| 633 | - **UID.** Preserved, sealed, hashed. **Re-importing the same UID updates |
| 634 | the event if the incoming `SEQUENCE` is higher, else skips** β so |
| 635 | importing an exported file twice does not duplicate. Our own UIDs are |
| 636 | `<ulid>@<instance-host>` and are never regenerated. |
| 637 | - **`RECURRENCE-ID` β override rows. `EXDATE` β `exdates`. `RDATE` β an |
| 638 | override row whose `recurrence_id` the rule does not generate**, which |
| 639 | the recurrence set renders as an addition (Β§2.3 step 4). Export of that |
| 640 | case is imperfect (an `RDATE` plus a `RECURRENCE-ID` for it); recorded |
| 641 | in the deferred table rather than over-designed. |
| 642 | - **All-day `DTEND` is exclusive** and the biggest single source of |
| 643 | off-by-one in the wild. One conversion function, one test, and a fixture |
| 644 | from each of five real exporters. |
| 645 | - **`VALARM`** β reminders for the importing member (Β§5.3). `ATTENDEE` β |
| 646 | counted and dropped tonight, with the count in the notice. |
| 647 | - **`DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`** β `updated_at`, `sequence`. |
| 648 | `CREATED` β `created_at` if present. |
| 649 | - **Line folding, `\,` `\;` `\n` escapes, `X-` properties** β folded lines |
| 650 | are the first parser bug everyone ships. Unknown `X-` properties are |
| 651 | dropped, not stored; storing them means round-tripping them, and that is |
| 652 | a corpus commitment we are not making tonight. |
| 653 | |
| 654 | ### 6.2 Export |
| 655 | |
| 656 | `GET /calendars/{id}.ics` and `GET /events/{id}.ics`, behind the session. |
| 657 | `PRODID:-//Oficina//Calendar//EN`, `VERSION:2.0`, `X-WR-CALNAME` from the |
| 658 | sealed name, `X-WR-TIMEZONE` from the team zone. |
| 659 | |
| 660 | **Tonight: IANA `TZID`s and no `VTIMEZONE` components.** RFC 5545 Β§3.6 says |
| 661 | `VTIMEZONE` MUST be present for every `TZID`; Google, Apple, Fastmail and |
| 662 | Thunderbird all resolve IANA `TZID`s without one; Outlook desktop does not |
| 663 | reliably. Generating `VTIMEZONE` from Go's `time.Location` means probing |
| 664 | transitions (Go does not export them) β ~120 lines and an evening of edge |
| 665 | cases (Lord Howe's 30-minute DST, Kolkata's none). It is the kind of thing |
| 666 | that eats an overnight, so it is deferred with a concrete trigger (Β§7) and |
| 667 | the export is honestly labelled non-conformant in the README until then. |
| 668 | |
| 669 | `UNTIL` always in `Z`. Overrides export with `RECURRENCE-ID;TZID=β¦`. Only the |
| 670 | exporting member's `VALARM`s, and none at all on a feed unless `?alarms=1`. |
| 671 | |
| 672 | ### 6.3 The round-trip corpus is a gate |
| 673 | |
| 674 | Import β export β import must be stable on our own files, and our export |
| 675 | must import cleanly into Google, Apple and Thunderbird (checked by hand on |
| 676 | the canary the first morning, then by fixture). Foreign fixtures: one each |
| 677 | from Google Calendar, Apple Calendar, Outlook, Thunderbird and Fastmail β |
| 678 | real exports, not hand-written. |
| 679 | |
| 680 | ### 6.4 The subscription feed, and what it means for the one rule |
| 681 | |
| 682 | **Yes: `GET /feed/{token}.ics`**, a capability URL outside the session |
| 683 | group, exactly Sheets' `/l/{token}`. Token hashed at rest. Per person and |
| 684 | per calendar, created and revoked from the sharing page. `ETag` and |
| 685 | `Last-Modified` from the calendar's max `updated_at`, `304` on match. |
| 686 | |
| 687 | What it means, said plainly: |
| 688 | |
| 689 | - **It does not break the rule.** The live instance decrypts for a |
| 690 | capability holder; the bucket still holds ciphertext; the token at rest |
| 691 | is a hash and not a capability. |
| 692 | - **It moves the plaintext somewhere else.** The subscriber's Google or |
| 693 | Apple account now holds a full plaintext copy and refreshes it. The |
| 694 | sharing UI says this in words before the URL is shown β the same shape as |
| 695 | Sheets Β§9.5's "a guest grant discloses the team name". |
| 696 | - **It is a hibernation anchor, and the cost is ours.** Apple polls |
| 697 | subscribed calendars as often as every 15 minutes; Thunderbird every 30; |
| 698 | Google roughly daily. Each poll wakes the instance. Three Apple |
| 699 | subscribers means the instance never sleeps. The `304` is cheap but the |
| 700 | wake is not free. `last_polled_at` and `poll_count` are shown beside |
| 701 | each feed on the sharing page β "polled 61 times this week" is |
| 702 | information a person can act on. |
| 703 | |
| 704 | Importing *from* an external feed (holidays, a sports fixture list) needs a |
| 705 | declared schedule (`carlos schedule set -every 6h -path /jobs/feeds`) and a |
| 706 | poller; deferred with a trigger (Β§7). |
| 707 | |
| 708 | --- |
| 709 | |
| 710 | ## 7. Scope β the maximally viable cut |
| 711 | |
| 712 | What one overnight session can finish to a deployed, tested standard, |
| 713 | starting from Sheets' skeleton to copy: roughly 3,500 lines of Go and |
| 714 | templates. The items below are ordered so that stopping after any line |
| 715 | leaves a coherent product. Gates come with the item they guard. |
| 716 | |
| 717 | ### Ships tonight |
| 718 | |
| 719 | 1. **Skeleton.** `cmd/calendar`, chi, GORM over `modernc.org/sqlite`, |
| 720 | additive migrations, `seal` lifted from Sheets with the new label, |
| 721 | magic-link sign-in with `$CALENDAR_ALLOWED_ADDRESSES` (Docs' shape), |
| 722 | `/mode` cookie with three states, `rst-*` attribute grammar on |
| 723 | v0.23.0, `estilo` theme, CSRF app-wide, catalog keys for twelve |
| 724 | locales (month and weekday names and the date pattern are keys β |
| 725 | there is no CLDR dependency), 300-line caps on Go and JS. **Gates from |
| 726 | here:** plaintext grep, no-content-in-logs, two-member 404-not-403 |
| 727 | isolation, scriptless-affordance diff, line caps. |
| 728 | 2. **Calendars and grants.** One team calendar created with the instance; |
| 729 | personal calendars per member, private by default; share to a member as |
| 730 | viewer or editor. Colour as OKLCH intent through Sheets' shim. |
| 731 | 3. **Events.** Create, edit, delete. Timed, all-day; zone from the member |
| 732 | preference; title, location, notes sealed. Ruling 1 storage. **Gate:** |
| 733 | the re-derive test (Β§3.1). |
| 734 | 4. **Views, all scriptless.** Today (default), Day, Week with server-packed |
| 735 | overlaps (Β§8.1), Month as a `<table>` with per-day chips, Agenda (next |
| 736 | fourteen days). Prev/next/today as links. Every empty slot is a link to |
| 737 | a prefilled `/events/new`. 320 px: month reflows to a stacked day list |
| 738 | in CSS; week claims the two-dimensional exemption and scrolls; day and |
| 739 | agenda reflow natively. |
| 740 | 5. **Recurrence subset, exceptions, split.** Expander with the rrule-go |
| 741 | oracle. `scope=this|following|all` on edit and delete. **Gate:** golden |
| 742 | vectors, and "editing this occurrence to 10:00 moves its reminder to |
| 743 | 09:50". |
| 744 | 6. **Reminders.** Rules, queue, `NextDue`, the one named timer, three |
| 745 | drain entrances, claim-lease, deterministic Message-ID, plain-text email, |
| 746 | the Today callout. **Gate:** kill-between-every-line test with a faked |
| 747 | control socket; the canary verifies a real wake the first morning. |
| 748 | 7. **ICS.** Import (synchronous), export per calendar and per event, |
| 749 | subscription feed with hashed token and `304`. **Gate:** round-trip |
| 750 | corpus on our own files plus the five foreign fixtures. |
| 751 | 8. **Two JS enhancements, both under 300 lines.** rastrillo's `datetime.js` |
| 752 | on every date field (free), and `create.js`: pointer-drag across empty |
| 753 | slots in day and week navigates to `/events/new?start=β¦&end=β¦`. Nothing |
| 754 | else. **There is no JSON route tonight, because no enhancement needs |
| 755 | one.** |
| 756 | 9. **Deploy** with `carlos deploy`, verify `GET /api/version` on the |
| 757 | process, not the edge. |
| 758 | |
| 759 | Honest risk ordering: ICS parsing edge cases and week-view packing are the |
| 760 | two items most likely to overrun. If the night is short, item 7 ships |
| 761 | import and export and defers the feed to the first morning β a calendar |
| 762 | without a feed is still a calendar; a calendar with a broken parser is a |
| 763 | calendar that eats files. |
| 764 | |
| 765 | ### Deferred, each with its trigger |
| 766 | |
| 767 | | Not built | Trigger | |
| 768 | |---|---| |
| 769 | | **Attendees and invitations** (`attendees` table, `ATTENDEE`/`ORGANIZER`, iTIP `REQUEST`/`REPLY` by email) | The first time someone on the canary wants a person *outside the members list* at an event. This needs the home's guest contract and the assertion helper, which is why it is not tonight. | |
| 770 | | **Guests** (per-calendar grants to non-members) | Same trigger; same dependency. | |
| 771 | | **Web push reminders** | A member says a reminder reached email too late to help. Needs a service worker and VAPID keys, script-only by nature β the scriptless floor is the Today callout. | |
| 772 | | **External feed subscription** (holidays, fixtures) | A member asks for public holidays. Needs a declared schedule and a poller; `carlos schedule set -every 6h`. | |
| 773 | | **`VTIMEZONE` emission** | An Outlook desktop import of our export shows a wrong time. Until then the README says the export omits it and names who resolves IANA `TZID`s without one. | |
| 774 | | **Expanding foreign RRULEs** | An imported file carries a rule outside the subset *and* the person needs its future occurrences shown. Vendoring rrule-go at runtime is the likely answer. | |
| 775 | | **Drag to move and resize** | Paul's first canary review says moving an event by form is painful. The menu equivalent (the form) exists first, per the house rule. | |
| 776 | | **Skip-ahead expansion** | A month render exceeds 50 ms on a canary profile with a real team's data. | |
| 777 | | **Sealed instants with an in-memory index** | A design exists for `NextDue` that does not need plaintext due times β a sealed index the boot path can open before any request. Until then Β§1 stands. | |
| 778 | | **Zone and week-start as home display preferences** | A person in two teams complains they set them twice. Same trigger as theme in the home spec. | |
| 779 | | **Search** (titles, via decrypt-and-scan at dogfood N) | Asked for once. Full-text has Docs' FTS5 problem and no design. | |
| 780 | | **Background import job for large files** | An import exceeds the request timeout. | |
| 781 | | **`RDATE` round-trip exactness** | The corpus shows an `RDATE` file coming back different. | |
| 782 | | **Working-hours shading, free/busy across members, per-event colours, attachments, print, natural-language beyond `datetime.js`, "find a time"** | Each: asked for, by a person on the canary, in the discussion. Listed so their absence is a decision. | |
| 783 | | **Anything with "AI" in it** | β | |
| 784 | |
| 785 | --- |
| 786 | |
| 787 | ## 8. Routes |
| 788 | |
| 789 | Sheets' style: session-guarded groups, `/api` only for JSON, capabilities |
| 790 | outside the session. Every row is scriptless unless the last column says |
| 791 | otherwise. |
| 792 | |
| 793 | | Method | Path | Scriptless | Notes | |
| 794 | |---|---|---|---| |
| 795 | | GET | `/` | yes | β `/today` when signed in; sign-in page otherwise | |
| 796 | | GET | `/signin` | yes | | |
| 797 | | POST | `/signin` | yes | allowlist-gated before the mail is sent; same 303 either way | |
| 798 | | GET | `/auth/verify` | yes | magic-link redemption | |
| 799 | | POST | `/signout` | yes | | |
| 800 | | POST | `/mode` | yes | light/dark/auto cookie, returns to `return=` | |
| 801 | | POST | `/_locale` | yes | mounted by the framework | |
| 802 | | GET | `/static/*` | β | fingerprinted assets | |
| 803 | | GET | `/feed/{token}.ics` | yes | **capability; outside the session.** `ETag`/`304`. `?alarms=1` | |
| 804 | | POST | `/jobs/reminders` | β | **platform tick; `carlos.Tick` guarded.** Drains the queue inside the request; 204 / 5xx | |
| 805 | | GET | `/api/next-due` | β | mounted by the framework from `Options.NextDue` | |
| 806 | | GET | `/today` | yes | the front door β Β§8.1 | |
| 807 | | GET | `/day/{yyyy-mm-dd}` | yes | one packed column + the day's list | |
| 808 | | GET | `/week/{yyyy-mm-dd}` | yes | seven packed columns; the date is any day in the week | |
| 809 | | GET | `/month/{yyyy-mm}` | yes | `<table>`; `+N more` links to the day | |
| 810 | | GET | `/agenda` | yes | next fourteen days as a list; `?from=` | |
| 811 | | GET | `/calendars` | yes | list, colours, archive | |
| 812 | | POST | `/calendars` | yes | create | |
| 813 | | GET | `/calendars/{id}` | yes | settings: name, colour, zone note | |
| 814 | | POST | `/calendars/{id}` | yes | rename, recolour | |
| 815 | | POST | `/calendars/{id}/archive` | yes | | |
| 816 | | GET | `/calendars/{id}/share` | yes | grants and feeds, with the poll counts and the plaintext-elsewhere notice | |
| 817 | | POST | `/calendars/{id}/share` | yes | grant a member | |
| 818 | | POST | `/calendars/{id}/share/revoke` | yes | | |
| 819 | | POST | `/calendars/{id}/feeds` | yes | mint a feed; shows the URL once | |
| 820 | | POST | `/calendars/{id}/feeds/revoke` | yes | | |
| 821 | | GET | `/calendars/{id}.ics` | yes | full export for the session member | |
| 822 | | GET | `/events/new` | yes | `?start=&end=&date=&calendar=` prefill; `create.js` navigates here | |
| 823 | | POST | `/events` | yes | create; kind radio; recurrence picker | |
| 824 | | GET | `/events/{id}` | yes | single, or a master's page (shows the rule and the exceptions) | |
| 825 | | GET | `/events/{id}/{yyyymmddThhmm}` | yes | **one occurrence** of a series | |
| 826 | | POST | `/events/{id}` | yes | edit; `scope=this\|following\|all` on a series or occurrence | |
| 827 | | POST | `/events/{id}/delete` | yes | same `scope` radio; "just this one" is the default | |
| 828 | | POST | `/events/{id}/reminders` | yes | add my reminder | |
| 829 | | POST | `/events/{id}/reminders/remove` | yes | | |
| 830 | | GET | `/events/{id}.ics` | yes | one VEVENT plus its overrides | |
| 831 | | GET | `/import` | yes | upload form | |
| 832 | | POST | `/import` | yes | synchronous; redirects to a notice with counts | |
| 833 | | GET | `/settings` | yes | zone, week start, default reminders, undeliverable reminders | |
| 834 | | POST | `/settings` | yes | | |
| 835 | | GET | `/healthz` | β | | |
| 836 | |
| 837 | **Enhanced (script adds, floor remains):** `/events/new` and the edit form |
| 838 | get `datetime.js`; `/day` and `/week` get `create.js`. That is the entire |
| 839 | enhanced surface tonight. The scriptless-affordance diff gate fetches every |
| 840 | page script-stripped and asserts no affordance is missing. |
| 841 | |
| 842 | --- |
| 843 | |
| 844 | ## 9. What makes it feel considered |
| 845 | |
| 846 | ### 9.1 Today is the front door, and it says how full the day is in a sentence |
| 847 | |
| 848 | The question a person opens a calendar to answer is "what's next", not |
| 849 | "what does September look like". So `/` is Today: this day's list, then the |
| 850 | rest of the week as a list, with the month one link away. |
| 851 | |
| 852 | At the top, one sentence, computed server-side, in the person's locale: |
| 853 | |
| 854 | > *Wednesday. Four things; the first at 09:30, the last ends at 17:00. |
| 855 | > Two hours and fifteen minutes unbooked between them.* |
| 856 | |
| 857 | And when there is nothing left to give: |
| 858 | |
| 859 | > *There is no unbooked hour today.* |
| 860 | |
| 861 | That sentence is the product's whole opinion about overfull days. It |
| 862 | states, never nags, never colours the day red. Colour never carries |
| 863 | meaning alone, and this carries it in words, which is stronger anyway. |
| 864 | Booked time counts meetings and commitments; notes do not count (Β§9.2). |
| 865 | |
| 866 | ### 9.2 A meeting, a commitment and a note are three different things |
| 867 | |
| 868 | Most calendar bloat comes from one event type pretending to be all three. |
| 869 | `kind` is one radio group at creation: |
| 870 | |
| 871 | - **Meeting** β has, or will have, other people. Gets the default |
| 872 | reminder. Counts as booked. |
| 873 | - **Commitment** β a promise to yourself: a focus block, the gym, "write |
| 874 | the spec". No reminder by default (you know), counts as booked, drawn |
| 875 | with an outline rather than a fill so it reads differently at a glance |
| 876 | *and* in greyscale. |
| 877 | - **Note** β a fact about the day, not a claim on it: "Anna on leave", |
| 878 | "release day". All-day by nature, never counts as busy, never reminded, |
| 879 | renders as a line above the grid. |
| 880 | |
| 881 | Exports map to `X-OFICINA-KIND` and `TRANSP:TRANSPARENT` for notes, so the |
| 882 | distinction survives a round trip and other calendars at least see notes |
| 883 | as free time. |
| 884 | |
| 885 | ### 9.3 The past is written in the past tense |
| 886 | |
| 887 | Agenda and Today render a future item as "09:00β09:30 Standup" and a past |
| 888 | one as "Standup, ended 09:30". Past items drop to muted weight. No strike, |
| 889 | no colour change, and the tense carries it for a screen reader too. It is |
| 890 | free and it is the difference between a list of times and a day. |
| 891 | |
| 892 | ### 9.4 No spanning bars in month view |
| 893 | |
| 894 | A multi-day event repeats as one chip per day with `β`/`β` continuation |
| 895 | markers. Spanning bars are the largest single source of layout complexity |
| 896 | in every calendar ever built, they break at week boundaries anyway, and the |
| 897 | information they carry β "this continues" β is a glyph. A month is for |
| 898 | scanning density, not reading; the day and week views are for reading. |
| 899 | This ships scriptless and stays that way even with JS. |
| 900 | |
| 901 | ### 9.5 One question on deleting a recurring event, and "all" is not the default |
| 902 | |
| 903 | *Just this one Β· This and the ones after it Β· All of them.* "Just this one" |
| 904 | is selected. "All of them" is last and is not a red button; it is the same |
| 905 | button with the count beside it: "All 52 of them". The number is the |
| 906 | warning. |
| 907 | |
| 908 | ### 9.6 Your zone is stated; the other zone is a whisper |
| 909 | |
| 910 | Every time on every page is the viewer's. When an event was authored |
| 911 | elsewhere, "09:00 Dublin" sits beside it in secondary text, and only then. |
| 912 | No `(GMT+1)` anywhere, ever. |
| 913 | |
| 914 | --- |
| 915 | |
| 916 | ## 10. The things everyone gets wrong, and what we do from commit one |
| 917 | |
| 918 | 1. **Storing UTC as truth.** Future zoned events drift when a government |
| 919 | changes the rules. Ruling 1: wall-clock plus zone is the record; the |
| 920 | instant is an index (Β§3.1). |
| 921 | 2. **All-day events as midnight-to-midnight timed events.** They land on |
| 922 | the wrong day for anyone in another zone β the "birthday on the wrong |
| 923 | date" bug. All-day is a third kind with no instant (Β§3.2). |
| 924 | 3. **Exclusive `DTEND` on all-day events**, shown inclusive in the UI. One |
| 925 | conversion function, one test, five real fixtures (Β§6.1). |
| 926 | 4. **Sniffing the browser's zone and silently rewriting the view.** Stated |
| 927 | preference; a notice, never a rewrite (Β§3.3). |
| 928 | 5. **Materialising occurrences.** Then "edit all" is a migration and |
| 929 | "forever" is a table that grows. Occurrences are computed per window, |
| 930 | overrides are rows, exdates are a column (Β§2.3). |
| 931 | 6. **Querying overrides by their original time.** An occurrence dragged |
| 932 | into the visible week from outside it vanishes. Query by the new time |
| 933 | (Β§2.3 step 4). |
| 934 | 7. **Orphaning overrides** when the master is deleted or its rule shortened. |
| 935 | Same transaction, always (Β§2.2). |
| 936 | 8. **`COUNT` after `EXDATE`.** COUNT counts generated instances before |
| 937 | exclusion; a test pins it. |
| 938 | 9. **`UNTIL` local versus UTC.** Lenient in, strict out (Β§3.5). |
| 939 | 10. **The 31st, the fifth Friday, and February 29.** Skip, per RFC Β§3.3.10; |
| 940 | never clamp, never crash. The copy says "in years that have one" for |
| 941 | the leap-day case, because Google does the other thing and someone |
| 942 | will ask. |
| 943 | 11. **A reminder system that assumes a live process.** Queue plus |
| 944 | `NextDue` plus one platform timer plus three drain entrances, work |
| 945 | inside the request (Β§5.1). |
| 946 | 12. **Duplicate reminders after a crash, with no idempotency key.** Claim |
| 947 | lease plus deterministic `Message-ID` (Β§5.2). And its mirror: a |
| 948 | reminder that never fires and nobody knows β `late_by_sec`, the |
| 949 | "could not deliver" list, and the "reminded late" notice. |
| 950 | 13. **Reminders as a property of the event on a shared calendar.** Per |
| 951 | person (Β§5.3). |
| 952 | 14. **`+N more` that opens nothing without script.** It is a link to the |
| 953 | day (Β§8). |
| 954 | 15. **Week-view geometry as the only carrier of duration.** Every chip |
| 955 | prints its times as text; the absolute positioning is decoration on an |
| 956 | ordered list (Β§8.1). |
| 957 | 16. **"Today" computed in the server's zone.** The viewer's, always (Β§3.4). |
| 958 | 17. **Calendar colour as the only encoding of which calendar an event is |
| 959 | on.** Every chip carries the calendar name in its accessible name, and |
| 960 | commitments are outlined rather than filled (Β§9.2). |
| 961 | 18. **A subscription feed treated as free.** It is a hibernation anchor |
| 962 | and a plaintext copy elsewhere; both are said in the sharing UI (Β§6.4). |
| 963 | 19. **Content in error strings.** An import error says "line 412: DTEND |
| 964 | before DTSTART", never the summary on line 410. Test-enforced. |
| 965 | 20. **Fifteen-minute time steps.** They hide the 09:10 meeting. Step is |
| 966 | five; `datetime.js` accepts anything. |
| 967 | |
| 968 | --- |
| 969 | |
| 970 | ## 11. The scriptless layout, concretely |
| 971 | |
| 972 | Called out separately because it is the constraint the user most wants |
| 973 | answered, and because it is easier than it looks once the server does the |
| 974 | geometry. |
| 975 | |
| 976 | **Month** β a `<table>`: `<thead>` of weekday names (catalog keys, in the |
| 977 | member's week-start order), one `<tr>` per week, one `<td>` per day with a |
| 978 | date heading that links to `/day/β¦`, a `+` link to `/events/new?date=β¦`, an |
| 979 | `<ol>` of at most four chips (each an `<a>` to the event or occurrence) and |
| 980 | a `+N more` link to the day. Today's cell carries `aria-current="date"` and |
| 981 | a text marker, not only a tint. Days outside the month are muted and still |
| 982 | real. At 320 px, CSS turns rows and cells into blocks with the date as a |
| 983 | heading and empty days collapsed β the month becomes a list, in CSS only. |
| 984 | |
| 985 | **Week and day** β an all-day strip (a plain list), then seven `<div>` |
| 986 | columns each `position: relative` at 24 Γ hour-height, each holding an |
| 987 | `<ol>` of chips positioned by inline `style="top:β¦%;height:β¦%;left:β¦%; |
| 988 | width:β¦%"` (rastrillo's default CSP allows inline style attributes β its |
| 989 | own partials use them). **The server runs the overlap packer:** cluster |
| 990 | events that transitively overlap, assign each a lane by earliest start, |
| 991 | width is `100%/lanes` and left is `lane Γ width`. Eighty lines of Go, unit |
| 992 | tested on the classic three-way overlap. DOM order is by day then start, |
| 993 | which is the reading order a screen reader wants; every chip prints its |
| 994 | times in text. Beneath the chips, 48 half-hour slot links per column with |
| 995 | `rst-sr-only` labels ("New event, Wednesday 09:30") and 24 px hit areas β |
| 996 | that is click-to-create with no script, and `create.js` turns a drag across |
| 997 | them into one navigation with a start and an end. |
| 998 | |
| 999 | **Agenda** β a list grouped by day heading. The best mobile view and the |
| 1000 | best scriptless view, and the shape Today is built from. |
| 1001 | |
| 1002 | **Forms** β rastrillo's `field-datetime` (native `datetime-local`, |
| 1003 | step 300) and `field-daterange`; the recurrence picker is a `<select>` for |
| 1004 | the frequency, a checkbox row for weekdays, a radio for "on day N / on the |
| 1005 | Nth weekday", a number for the interval, and a radio for the end β all |
| 1006 | plain controls. The all-day checkbox swaps `datetime` fields for `date` |
| 1007 | fields on the next render, and `datetime.js` does it live. |
| 1008 | |
| 1009 | --- |
| 1010 | |
| 1011 | ## 12. Rulings this brainstorm asks Paul to make |
| 1012 | |
| 1013 | Proposed for the spec's Β§1 table, each with its rejected alternative: |
| 1014 | |
| 1015 | | # | Proposed ruling | Rejected | |
| 1016 | |---|---|---| |
| 1017 | | 1 | **Store what the person said: wall-clock plus IANA zone is the record; the instant is a derived index** (Β§3.1) | UTC as truth; storing both as peers | |
| 1018 | | 2 | **Sealed at rest for content; times, zones and rules plaintext and published** (Β§1, Β§4.3) | Sealed instants with an in-memory index (deferred, not rejected) | |
| 1019 | | 3 | **Recurrence subset is Google's picker; hand-rolled expander; rrule-go as test oracle; foreign rules stored, shown once, never refused** (Β§2.1) | rrule-go at runtime; refusing foreign rules | |
| 1020 | | 4 | **Occurrences are computed, never rows; overrides are rows; deletions are `EXDATE`; "following" is a split** (Β§2.2β2.3) | Materialising; tombstone rows; `RANGE=THISANDFUTURE` | |
| 1021 | | 5 | **One reminder queue, one `NextDue`, one named platform timer, three drain entrances, work inside the request** (Β§5.1) | A timer per reminder (host cap, materialisation); a ticker | |
| 1022 | | 6 | **At-least-once with a claim lease and a deterministic `Message-ID`; lateness recorded and shown** (Β§5.2) | Mark-then-send (loses reminders); exactly-once claims | |
| 1023 | | 7 | **Reminders are per person** (Β§5.3) | Per event | |
| 1024 | | 8 | **Email is the only channel tonight; the Today callout is the scriptless in-app reminder** (Β§5.4) | Web push first | |
| 1025 | | 9 | **The viewer's zone is a stated preference, never sniffed** (Β§3.3) | Browser detection with silent update | |
| 1026 | | 10 | **Floating events are import/export only** (Β§3.2) | Offering them in the UI | |
| 1027 | | 11 | **Subscription feeds ship as capability URLs, per person, with the plaintext-elsewhere and hibernation costs stated in the UI** (Β§6.4) | No feeds; feeds without the disclosure | |
| 1028 | | 12 | **Export emits IANA `TZID`s without `VTIMEZONE` tonight, labelled non-conformant** (Β§6.2) | Generating `VTIMEZONE` tonight; refusing to export | |
| 1029 | | 13 | **Three kinds: meeting, commitment, note** (Β§9.2) | One event type with flags | |
| 1030 | | 14 | **Today is the front door, with the day's shape in one sentence** (Β§9.1) | Month grid as landing | |
| 1031 | | 15 | **No spanning bars in month view, with or without script** (Β§9.4) | Bars as the JS enhancement | |
| 1032 | | 16 | **Sign in locally by magic link and allowlist tonight; adopt the home's assertion when the helper exists** (Β§0) | Blocking on the helper | |
| 1033 | | 17 | **Import is synchronous, 10 MB, nothing persisted but events** (Β§6.1) | A background job and a sealed blob store | |
| 1034 | |
| 1035 | --- |
| 1036 | |
| 1037 | ## 13. Open |
| 1038 | |
| 1039 | - **How the activator wakes us from `NextDue`.** `serve.go` says it |
| 1040 | "hibernates knowing when to wake it"; whether the wake is a bare process |
| 1041 | start or a POST somewhere is in the platform spec |
| 1042 | (`2026-08-23-scheduled-work-design.md Β§7`), which this session could not |
| 1043 | read. The three-entrance drain is designed so that either answer works; |
| 1044 | the first canary morning confirms which it is. |
| 1045 | - **Whether `carlos.ScheduleAt` is reachable from a hibernating exec child |
| 1046 | at boot** β `schedule.go` says the socket is bound before spawn, so it |
| 1047 | should be. Verified on the canary, not assumed. |
| 1048 | - **Locale month and weekday names versus `Intl`.** The scriptless render |
| 1049 | uses catalog keys; `datetime.js` uses `Intl`. A browser-drive gate that |
| 1050 | compares them per locale would catch a divergence; whether it is worth an |
| 1051 | overnight hour is Paul's call. |
| 1052 | - **The suite's own domain** β inherited from Sheets Β§15; the feed URL is |
| 1053 | the string most expensive to change later after the RP host. |
| 1054 | |