oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import "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
19type 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.
133const (
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.
141func OccupiesTime(kind string) bool { return kind != KindNote }
142
143const (
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.
156type 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.
192type 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.
243type 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