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 (
4 "context"
5 "errors"
6 "fmt"
7 "sort"
8
9 "gorm.io/gorm"
10)
11
12// eventRow is the join every range query reads: the event plus the two
13// things a chip needs from its calendar and the viewer's grant.
14type eventRow struct {
15 Event
16 Role string
17 CalendarName []byte
18 CalendarHue int
19}
20
21// EventsInRange returns everything a person may see that overlaps the
22// half-open UTC window [from, to), with series expanded and overrides
23// substituted.
24//
25// The two-pass shape is what makes moved occurrences behave. Overrides
26// are stored against the occurrence's ORIGINAL time and may be moved to
27// any other, so:
28//
29// 1. Expand each master over the window and substitute any override
30// matching the occurrence's original key. An override that moved
31// OUT of the window is dropped here, which is correct — it is no
32// longer in these days.
33// 2. Separately collect overrides whose own times land IN the window,
34// and add the ones pass 1 did not already place. That is the
35// occurrence dragged in from last week, which pass 1 cannot see
36// because the master never generates it here.
37//
38// Skipping pass 2 is the classic vanishing-event bug; skipping the
39// dedupe is the classic duplicated one.
40func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]EventView, error) {
41 return s.eventsInRange(s.g.WithContext(ctx), personID, from, to, false)
42}
43
44// ErrIncompleteExpansion says a busy series in the window could not be
45// expanded to completion — its rule is outside the subset, or it ran
46// out of steps before reaching the window — so the answer is not a
47// full account of the person's time.
48var ErrIncompleteExpansion = errors.New("calendar: a busy series could not be fully expanded")
49
50// EventsInRangeStrict is EventsInRange for anyone deciding FREE time.
51//
52// The tolerant reader renders a foreign rule as its first occurrence
53// and a truncated expansion as whatever it reached, because a view
54// that shows less is better than a view that shows nothing. A booking
55// page cannot afford either: an occurrence it does not see is a slot it
56// offers. So this returns ErrIncompleteExpansion for any master that
57// occupies time, is not cancelled, and either has a rule ParseRule
58// rejects or ran out of steps (booking spec §4.1, B23). The rule is
59// still stored verbatim, per ruling 4.
60func (s *Store) EventsInRangeStrict(ctx context.Context, personID, from, to int64) ([]EventView, error) {
61 return s.eventsInRange(s.g.WithContext(ctx), personID, from, to, true)
62}
63
64// eventsInRange runs on any *gorm.DB — the store's own, or a caller's
65// transaction — so a hold can re-read busy time behind the write lock.
66func (s *Store) eventsInRange(db *gorm.DB, personID, from, to int64, strict bool) ([]EventView, error) {
67 rows, err := s.rangeRows(db, personID, from, to)
68 if err != nil {
69 return nil, err
70 }
71
72 // Overrides for every series touched, keyed by series and original
73 // occurrence. Fetched in one query rather than per occurrence: a
74 // query inside an expansion loop is a query per day per series.
75 //
76 // An override row in the window volunteers its own series too. Its
77 // master may have FINISHED before the window — excluded by
78 // until_utc — in which case nothing else names that series, and
79 // without this the occurrence dragged past the series' end would be
80 // skipped in pass 1 and never loaded for pass 2: a moved meeting
81 // that vanishes.
82 var masterIDs []int64
83 seen := map[int64]bool{}
84 for _, r := range rows {
85 switch {
86 case r.RRule != "" && r.SeriesID == nil:
87 if !seen[r.ID] {
88 seen[r.ID] = true
89 masterIDs = append(masterIDs, r.ID)
90 }
91 case r.SeriesID != nil:
92 if !seen[*r.SeriesID] {
93 seen[*r.SeriesID] = true
94 masterIDs = append(masterIDs, *r.SeriesID)
95 }
96 }
97 }
98 overrides, err := s.overridesFor(db, personID, masterIDs)
99 if err != nil {
100 return nil, err
101 }
102
103 placed := map[string]bool{}
104 var out []EventView
105
106 for _, r := range rows {
107 switch {
108 case r.SeriesID != nil:
109 // Handled below, in pass 2 — an override is never rendered
110 // as if it were an independent event.
111 continue
112
113 case r.RRule == "":
114 v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
115 if err != nil {
116 return nil, err
117 }
118 out = append(out, v)
119
120 default:
121 // A rule this app cannot expand still has its own first
122 // occurrence, which is a real event on a real day.
123 rule, perr := ParseRule(r.RRule)
124 if perr != nil {
125 if strict && OccupiesTime(r.Kind) && r.Status != StatusCancelled {
126 return nil, ErrIncompleteExpansion
127 }
128 v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
129 if err != nil {
130 return nil, err
131 }
132 if v.EndUTC > from && v.StartUTC < to {
133 out = append(out, v)
134 }
135 continue
136 }
137 occs, complete := ExpandComplete(r.StartLocal, r.EndLocal, r.TZ, r.AllDay, rule, r.Exdates, from, to)
138 if !complete && strict && OccupiesTime(r.Kind) && r.Status != StatusCancelled {
139 return nil, ErrIncompleteExpansion
140 }
141 for _, occ := range occs {
142 key := overrideKey(r.ID, occ.Local)
143 if ov, ok := overrides[key]; ok {
144 placed[key] = true
145 if ov.DeletedAt != nil {
146 continue
147 }
148 v, err := s.openEvent(ov.Event, ov.Role, ov.CalendarName, ov.CalendarHue)
149 if err != nil {
150 return nil, err
151 }
152 // An override moved outside the window is not in
153 // these days any more.
154 if v.OccEndUTC <= from || v.OccStartUTC >= to {
155 continue
156 }
157 v.OccurrenceLocal = occ.Local
158 out = append(out, v)
159 continue
160 }
161 v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
162 if err != nil {
163 return nil, err
164 }
165 v.OccStartUTC, v.OccEndUTC = occ.StartUTC, occ.EndUTC
166 v.OccurrenceLocal = occ.Local
167 out = append(out, v)
168 }
169 }
170 }
171
172 // Pass 2: overrides dragged into the window from an original the
173 // expansion never reached.
174 for key, ov := range overrides {
175 if placed[key] || ov.DeletedAt != nil {
176 continue
177 }
178 if ov.EndUTC <= from || ov.StartUTC >= to {
179 continue
180 }
181 v, err := s.openEvent(ov.Event, ov.Role, ov.CalendarName, ov.CalendarHue)
182 if err != nil {
183 return nil, err
184 }
185 v.OccurrenceLocal = ov.RecurrenceIDLocal
186 out = append(out, v)
187 }
188
189 sortOccurrences(out)
190 return out, nil
191}
192
193// rangeRows reads the candidate events: anything overlapping the window,
194// plus every series master that could still be producing in it.
195func (s *Store) rangeRows(db *gorm.DB, personID, from, to int64) ([]eventRow, error) {
196 var rows []eventRow
197 err := db.
198 Model(&Event{}).
199 Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue").
200 Joins("JOIN calendars ON calendars.id = events.calendar_id AND calendars.archived_at IS NULL").
201 Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID).
202 Where("events.deleted_at IS NULL").
203 Where(`
204 -- a single, or an override, overlapping the window
205 (events.rrule = '' AND events.end_utc > ? AND events.start_utc < ?)
206 -- or a master that starts before the window ends and has
207 -- not already finished. until_utc is NULL for a forever
208 -- series and for one bounded by COUNT, whose end cannot be
209 -- known without expanding it — so those are always
210 -- candidates, and the expander bounds them.
211 --
212 -- UNTIL bounds the last occurrence's START, not its end, so
213 -- "finished" is until_utc plus one occurrence's length: a
214 -- final occurrence that runs for four days is still in
215 -- progress two days after the last start the rule allows.
216 -- Plus a 25-hour day of slack, because an occurrence's
217 -- elapsed length is not the master's across a DST day: the
218 -- fold makes a four-hour night shift five hours long, and
219 -- a bound measured from the master would have excluded it
220 -- from a query that opened in its fifth hour. A day's extra
221 -- expansion is cheap; a lost occurrence is a double-booking.
222 OR (events.rrule <> '' AND events.start_utc < ?
223 AND (events.until_utc IS NULL OR events.until_utc + (events.end_utc - events.start_utc) + 90000 >= ?))
224 `, from, to, to, from).
225 Scan(&rows).Error
226 if err != nil {
227 return nil, fmt.Errorf("calendar: range query: %w", err)
228 }
229 return rows, nil
230}
231
232// overridesFor reads every override of the given series, keyed by
233// series and ORIGINAL occurrence.
234func (s *Store) overridesFor(db *gorm.DB, personID int64, seriesIDs []int64) (map[string]eventRow, error) {
235 out := map[string]eventRow{}
236 if len(seriesIDs) == 0 {
237 return out, nil
238 }
239 var rows []eventRow
240 err := db.
241 Model(&Event{}).
242 Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue").
243 Joins("JOIN calendars ON calendars.id = events.calendar_id").
244 Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID).
245 Where("events.series_id IN ?", seriesIDs).
246 Scan(&rows).Error
247 if err != nil {
248 return nil, fmt.Errorf("calendar: overrides query: %w", err)
249 }
250 for _, r := range rows {
251 out[overrideKey(*r.SeriesID, r.RecurrenceIDLocal)] = r
252 }
253 return out, nil
254}
255
256func overrideKey(seriesID int64, local string) string {
257 return fmt.Sprintf("%d/%s", seriesID, local)
258}
259
260// sortOccurrences puts a day in reading order: earliest first, all-day
261// events ahead of timed ones that start at the same moment, then by
262// title so the order is stable across requests.
263//
264// Stability matters more than it looks: an unstable sort makes a page
265// reorder itself between two identical requests, which reads as data
266// changing when nothing has.
267func sortOccurrences(v []EventView) {
268 sort.SliceStable(v, func(i, j int) bool {
269 if v[i].OccStartUTC != v[j].OccStartUTC {
270 return v[i].OccStartUTC < v[j].OccStartUTC
271 }
272 if v[i].AllDay != v[j].AllDay {
273 return v[i].AllDay
274 }
275 if v[i].Title != v[j].Title {
276 return v[i].Title < v[j].Title
277 }
278 return v[i].ID < v[j].ID
279 })
280}
281