| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | type 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. |
| 40 | func (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. |
| 48 | var 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. |
| 60 | func (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. |
| 66 | func (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. |
| 195 | func (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. |
| 234 | func (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 | |
| 256 | func 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. |
| 267 | func 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 | |