| 1 | package calendar |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Event is one table for three things: a single event, a series master, |
| 6 | // and an override of one occurrence of a series. |
| 7 | // |
| 8 | // One table rather than three because they are the same thing to every |
| 9 | // query a view makes — "what is in this range" does not care — and |
| 10 | // because the RFC models them as one VEVENT shape distinguished by the |
| 11 | // presence of RRULE and RECURRENCE-ID. Three tables would mean three |
| 12 | // range queries and a union at every view. |
| 13 | // |
| 14 | // Which is which: |
| 15 | // |
| 16 | // single RRule == "" SeriesID == nil |
| 17 | // master RRule != "" SeriesID == nil |
| 18 | // override RRule == "" SeriesID != nil, RecurrenceIDLocal set |
| 19 | type Event struct { |
| 20 | ID int64 `gorm:"primaryKey"` |
| 21 | CalendarID int64 `gorm:"column:calendar_id;not null;index"` |
| 22 | |
| 23 | // Identity for ICS round-trips. The hash is the lookup key that |
| 24 | // re-import dedupes on; the sealed copy is what exports. |
| 25 | // |
| 26 | // Sealed, because some generators build a UID out of the title — |
| 27 | // "standup-with-jane@example.com" is a real shape — so a plaintext |
| 28 | // UID column would be a plaintext title column wearing a hat. |
| 29 | UIDHash string `gorm:"column:uid_hash;not null;index"` |
| 30 | UIDSealed []byte `gorm:"column:uid_sealed"` |
| 31 | |
| 32 | // Series linkage. An override has both of these; a master and a |
| 33 | // single have neither. |
| 34 | SeriesID *int64 `gorm:"column:series_id;index"` |
| 35 | |
| 36 | // RecurrenceIDLocal names which occurrence this overrides, by its |
| 37 | // ORIGINAL local start ("2026-09-02T09:00") — never its new one. |
| 38 | // |
| 39 | // That distinction is the whole bug behind an occurrence that |
| 40 | // vanishes when you drag it: the expander skips the original slot, |
| 41 | // so an override keyed by its new time leaves the original |
| 42 | // unskipped and itself unmatched, and the day shows both the |
| 43 | // unmoved occurrence and the moved one. Overrides are matched by |
| 44 | // this column and rendered at their new time. |
| 45 | RecurrenceIDLocal string `gorm:"column:recurrence_id_local;not null"` |
| 46 | |
| 47 | TitleSealed []byte `gorm:"column:title_sealed"` |
| 48 | LocationSealed []byte `gorm:"column:location_sealed"` |
| 49 | NotesSealed []byte `gorm:"column:notes_sealed"` |
| 50 | |
| 51 | // The record, per ruling 1: what the person said. |
| 52 | AllDay bool `gorm:"column:all_day;not null"` |
| 53 | |
| 54 | // StartLocal and EndLocal are wall-clock strings in TZ — |
| 55 | // "2026-09-02T09:00", or "2026-09-02" when AllDay. |
| 56 | // |
| 57 | // EndLocal is EXCLUSIVE, always, for both kinds. A one-day event on |
| 58 | // the 2nd ends on the 3rd. That is the RFC's rule and it is also |
| 59 | // the only one that makes range arithmetic work without a special |
| 60 | // case; an inclusive end is how a calendar ends up showing a |
| 61 | // one-day event across two days, and how a week query |
| 62 | // double-counts its boundary. |
| 63 | StartLocal string `gorm:"column:start_local;not null"` |
| 64 | EndLocal string `gorm:"column:end_local;not null"` |
| 65 | |
| 66 | // TZ is the IANA zone the local strings are written in. Empty means |
| 67 | // floating — accepted on import, never creatable in the UI, because |
| 68 | // a floating event on a shared calendar means different instants |
| 69 | // for different readers, which is a calendar telling two people |
| 70 | // different things about one meeting. |
| 71 | // |
| 72 | // Ignored when AllDay: a date has no zone. |
| 73 | TZ string `gorm:"column:tz;not null"` |
| 74 | |
| 75 | // Derived from the three fields above, index only, rebuilt by the |
| 76 | // re-derive sweep whenever tzdata changes. Never the record. |
| 77 | StartUTC int64 `gorm:"column:start_utc;not null;index"` |
| 78 | EndUTC int64 `gorm:"column:end_utc;not null;index"` |
| 79 | |
| 80 | // Recurrence. Plaintext: it is structure rather than prose, and it |
| 81 | // reveals nothing the instants do not already. |
| 82 | RRule string `gorm:"column:rrule;not null"` |
| 83 | |
| 84 | // RRuleForeign marks a rule outside the subset this app can expand. |
| 85 | // Stored verbatim, shown once on the event's page, never refused |
| 86 | // and never silently dropped — an import that eats a rule it does |
| 87 | // not understand is an import that eats data. |
| 88 | RRuleForeign bool `gorm:"column:rrule_foreign;not null"` |
| 89 | |
| 90 | // UntilUTC is the series' last possible instant, indexed so a range |
| 91 | // query can skip masters that ended before the window. Nil for a |
| 92 | // series with no end. |
| 93 | UntilUTC *int64 `gorm:"column:until_utc;index"` |
| 94 | |
| 95 | // Exdates is the comma-separated local datetimes this series skips |
| 96 | // — a deleted single occurrence. Deletions are EXDATEs rather than |
| 97 | // tombstone rows because that is what exports, and because a |
| 98 | // deleted occurrence has nothing else to say. |
| 99 | Exdates string `gorm:"column:exdates;not null"` |
| 100 | |
| 101 | // Kind is "meeting", "commitment" or "note" — the app's own |
| 102 | // vocabulary, not the RFC's. See the event form for why three. |
| 103 | Kind string `gorm:"column:kind;not null"` |
| 104 | |
| 105 | // Status is "confirmed", "tentative" or "cancelled". |
| 106 | Status string `gorm:"column:status;not null"` |
| 107 | |
| 108 | // Sequence is the RFC's SEQUENCE, bumped on every edit. It is what |
| 109 | // lets a re-import tell a newer version of an event from an older |
| 110 | // one rather than guessing from timestamps. |
| 111 | Sequence int `gorm:"column:sequence;not null"` |
| 112 | |
| 113 | CreatedBy int64 `gorm:"column:created_by;not null"` |
| 114 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 115 | |
| 116 | // UpdatedAt exports as DTSTAMP and LAST-MODIFIED. |
| 117 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 118 | DeletedAt *time.Time `gorm:"column:deleted_at;index"` |
| 119 | } |
| 120 | |
| 121 | // The three kinds an event can be. Not the RFC's vocabulary — the RFC |
| 122 | // has no opinion — but the distinction a person actually makes. |
| 123 | // |
| 124 | // A meeting has other people in it and being late matters. A commitment |
| 125 | // is time you have promised yourself: it occupies the day, and the |
| 126 | // difference between it and a meeting is who is inconvenienced when it |
| 127 | // moves. A note is a thing that is true of a day without occupying any |
| 128 | // of it — a birthday, a deadline, a bin collection. |
| 129 | // |
| 130 | // They differ in three ways that would otherwise need three settings: |
| 131 | // reminder defaults, whether they count toward the day's booked hours, |
| 132 | // and how they render. |
| 133 | const ( |
| 134 | KindMeeting = "meeting" |
| 135 | KindCommitment = "commitment" |
| 136 | KindNote = "note" |
| 137 | ) |
| 138 | |
| 139 | // OccupiesTime reports whether a kind occupies time in the day's arithmetic. A |
| 140 | // note does not: a birthday does not make your Tuesday fuller. |
| 141 | func OccupiesTime(kind string) bool { return kind != KindNote } |
| 142 | |
| 143 | const ( |
| 144 | StatusConfirmed = "confirmed" |
| 145 | StatusTentative = "tentative" |
| 146 | StatusCancelled = "cancelled" |
| 147 | ) |
| 148 | |
| 149 | // Reminder is the RULE, and it belongs to a PERSON rather than to an |
| 150 | // event. |
| 151 | // |
| 152 | // "Remind me ten minutes before" is a fact about me. Two people on one |
| 153 | // meeting want different warnings, and a reminder stored on the event |
| 154 | // would either give them the same one or need a person column anyway — |
| 155 | // at which point it is this table with a worse name. |
| 156 | type Reminder struct { |
| 157 | ID int64 `gorm:"primaryKey"` |
| 158 | EventID int64 `gorm:"column:event_id;not null;index"` |
| 159 | PersonID int64 `gorm:"column:person_id;not null;index"` |
| 160 | |
| 161 | // OffsetMin is minutes BEFORE the start. Negative means after, |
| 162 | // which is what a "did that happen?" nudge is. |
| 163 | OffsetMin int `gorm:"column:offset_min;not null"` |
| 164 | |
| 165 | // Channel is "email" today. A column rather than an assumption, so |
| 166 | // web push arrives as a value rather than a schema change. |
| 167 | Channel string `gorm:"column:channel;not null"` |
| 168 | |
| 169 | // UndeliverableAt records that this rule gave up on an occurrence |
| 170 | // after MaxReminderAttempts, and it lives HERE rather than on the |
| 171 | // queue row because the queue row has to move on. |
| 172 | // |
| 173 | // One row per rule means the dead-letter marker and the next |
| 174 | // occurrence want the same row, and only one of them can have it. |
| 175 | // Keeping the marker there is what made a recurring reminder die |
| 176 | // permanently the first time a relay was down: the row stayed |
| 177 | // fired, nothing queued the next occurrence, and a daily standup |
| 178 | // silently lost its reminders forever. Recording it on the rule |
| 179 | // instead lets the queue row be rescheduled while the failure stays |
| 180 | // visible on the settings page. |
| 181 | UndeliverableAt *int64 `gorm:"column:undeliverable_at"` |
| 182 | |
| 183 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 184 | } |
| 185 | |
| 186 | // ReminderQueue holds the NEXT un-fired occurrence per rule, and only |
| 187 | // that one. |
| 188 | // |
| 189 | // This is what keeps a forever-daily standup costing one row instead of |
| 190 | // infinity. When a row fires, the next occurrence of its rule is |
| 191 | // computed and written; nothing is ever materialised ahead. |
| 192 | type ReminderQueue struct { |
| 193 | ID int64 `gorm:"primaryKey"` |
| 194 | |
| 195 | // At most one pending row per rule — the uniqueness is the |
| 196 | // invariant, enforced by the index rather than by remembering. |
| 197 | ReminderID int64 `gorm:"column:reminder_id;not null;uniqueIndex"` |
| 198 | |
| 199 | // OccurrenceKey is "{event_id}/{recurrence_id_local}" — the |
| 200 | // idempotency key. Delivery is at-least-once, so the question a |
| 201 | // retry has to answer is "have I sent THIS occurrence", and the |
| 202 | // wall clock cannot answer it. |
| 203 | OccurrenceKey string `gorm:"column:occurrence_key;not null;index"` |
| 204 | |
| 205 | DueAt int64 `gorm:"column:due_at;not null;index"` |
| 206 | |
| 207 | // ClaimedAt is the lease: a drain claims a row before sending, so |
| 208 | // two drains racing cannot both send. It expires, because a |
| 209 | // process killed mid-send must not leave a reminder claimed |
| 210 | // forever — better twice than never. |
| 211 | ClaimedAt *int64 `gorm:"column:claimed_at"` |
| 212 | |
| 213 | // ClaimID is WHO holds the lease: random per claim, cleared by any |
| 214 | // rewrite of the row. Every completion — fired, failed, deferred, |
| 215 | // advanced — is conditional on it, so a worker whose claim was |
| 216 | // overtaken by a reschedule cannot mark the replacement fired or |
| 217 | // advance it past a time it never announced. claimed_at alone is |
| 218 | // whole seconds and two claims can share one (booking spec B22). |
| 219 | ClaimID *string `gorm:"column:claim_id"` |
| 220 | |
| 221 | // RetryAt is when a DEFERRED row (the sender was busy; no attempt |
| 222 | // was made) becomes eligible again. It is separate from due_at |
| 223 | // because due_at IS the occurrence minus the offset — the meeting |
| 224 | // time is reconstructed from it — and a retry written there moved |
| 225 | // the meeting. |
| 226 | RetryAt *int64 `gorm:"column:retry_at"` |
| 227 | |
| 228 | FiredAt *int64 `gorm:"column:fired_at"` |
| 229 | Attempts int `gorm:"column:attempts;not null"` |
| 230 | |
| 231 | // LateBySec is the honesty column. An instance that was asleep |
| 232 | // fires late, and the Today page says so rather than pretending the |
| 233 | // reminder arrived on time. |
| 234 | LateBySec int64 `gorm:"column:late_by_sec;not null"` |
| 235 | } |
| 236 | |
| 237 | // Feed is a subscription capability: a URL somebody pastes into Google |
| 238 | // or Apple Calendar. |
| 239 | // |
| 240 | // The token is hashed, like a share link. A plaintext token in this |
| 241 | // table would be a live read capability sitting in the backup bucket, |
| 242 | // which is the one thing the bucket is not allowed to hold. |
| 243 | type Feed struct { |
| 244 | ID int64 `gorm:"primaryKey"` |
| 245 | PersonID int64 `gorm:"column:person_id;not null;index"` |
| 246 | CalendarID int64 `gorm:"column:calendar_id;not null;index"` |
| 247 | TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"` |
| 248 | |
| 249 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 250 | RevokedAt *time.Time `gorm:"column:revoked_at"` |
| 251 | |
| 252 | // LastPolledAt and PollCount are shown on the share page, because a |
| 253 | // feed is not free: every poll wakes a hibernating instance, and |
| 254 | // Apple polls roughly every fifteen minutes forever. Somebody |
| 255 | // deciding whether to keep a feed should be able to see what it |
| 256 | // costs. |
| 257 | LastPolledAt *time.Time `gorm:"column:last_polled_at"` |
| 258 | PollCount int64 `gorm:"column:poll_count;not null"` |
| 259 | } |
| 260 | |