| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "time" |
| 6 | |
| 7 | "gorm.io/gorm" |
| 8 | ) |
| 9 | |
| 10 | // BusyFor is everything the host can see (B2) that occupies time, as |
| 11 | // plain UTC spans, over [from, to) plus a day either side — so a buffer |
| 12 | // can reach across the window's edge and a busy span that ends five |
| 13 | // minutes before `from` still removes the first slot. |
| 14 | // |
| 15 | // It reads through EventsInRange, so a hold — a tentative event — is |
| 16 | // busy for free, and so the two range-query fixes in §4.1 are what make |
| 17 | // a moved occurrence and a long final occurrence count here. |
| 18 | // |
| 19 | // skip is the caller's exclusion set: the event being rescheduled, and |
| 20 | // held bookings whose hold has lapsed but which the sweep has not yet |
| 21 | // freed. This function does not know about bookings and does not build |
| 22 | // that set. |
| 23 | func (s *Store) BusyFor(ctx context.Context, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) { |
| 24 | return s.busyOn(s.g.WithContext(ctx), host, from, to, skip) |
| 25 | } |
| 26 | |
| 27 | // busyOn is BusyFor on a caller's transaction. It reads through the |
| 28 | // STRICT range reader: a busy series that could not be fully expanded |
| 29 | // is an error here, never a free afternoon (booking spec B23). |
| 30 | func (s *Store) busyOn(tx *gorm.DB, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) { |
| 31 | hostLoc, err := Zone(host.Zone) |
| 32 | if err != nil { |
| 33 | return nil, err |
| 34 | } |
| 35 | events, err := s.eventsInRange(tx, host.ID, from-86400, to+86400, true) |
| 36 | if err != nil { |
| 37 | return nil, err |
| 38 | } |
| 39 | var out []Busy |
| 40 | for _, e := range events { |
| 41 | if !OccupiesTime(e.Kind) || e.Status == StatusCancelled || skip[e.ID] { |
| 42 | continue |
| 43 | } |
| 44 | if !e.AllDay { |
| 45 | out = append(out, Busy{StartUTC: e.OccStartUTC, EndUTC: e.OccEndUTC}) |
| 46 | continue |
| 47 | } |
| 48 | spans, err := allDayBusy(e, host.Zone, hostLoc) |
| 49 | if err != nil { |
| 50 | return nil, err |
| 51 | } |
| 52 | out = append(out, spans...) |
| 53 | } |
| 54 | return out, nil |
| 55 | } |
| 56 | |
| 57 | // allDayBusy turns an all-day occurrence into one whole HOST day per |
| 58 | // date it covers. |
| 59 | // |
| 60 | // An all-day event is dates, not instants, and its UTC columns are an |
| 61 | // index at midnight in the event's own zone — UTC when it has none, |
| 62 | // which is every all-day event the form makes. So the dates are |
| 63 | // recovered in that zone, from the occurrence's start through the |
| 64 | // second before its exclusive end, and each date is then re-read as |
| 65 | // the host's day: a 9 September commitment blocks all of 9 September |
| 66 | // in Tokyo, not the UTC day that starts nine hours into it. |
| 67 | func allDayBusy(e EventView, hostZone string, hostLoc *time.Location) ([]Busy, error) { |
| 68 | evLoc, err := Zone(e.TZ) |
| 69 | if err != nil { |
| 70 | // An imported zone this build cannot resolve: fall back to |
| 71 | // UTC rather than drop the event. Dropping it would book the |
| 72 | // host over it; a day of slack in the wrong direction is the |
| 73 | // lesser harm. |
| 74 | evLoc = time.UTC |
| 75 | } |
| 76 | first := time.Unix(e.OccStartUTC, 0).In(evLoc) |
| 77 | last := time.Unix(e.OccEndUTC-1, 0).In(evLoc) |
| 78 | firstDate := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, evLoc) |
| 79 | lastDate := time.Date(last.Year(), last.Month(), last.Day(), 0, 0, 0, 0, evLoc) |
| 80 | |
| 81 | var out []Busy |
| 82 | for d := firstDate; !d.After(lastDate); d = d.AddDate(0, 0, 1) { |
| 83 | // The DATE, rebuilt in the host's zone before DayBounds sees |
| 84 | // it. Handing DayBounds the event-zone midnight would let it |
| 85 | // land on the previous date for a host west of the event. |
| 86 | day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, hostLoc) |
| 87 | start, end, err := DayBounds(day, hostZone) |
| 88 | if err != nil { |
| 89 | return nil, err |
| 90 | } |
| 91 | out = append(out, Busy{StartUTC: start, EndUTC: end}) |
| 92 | } |
| 93 | return out, nil |
| 94 | } |
| 95 | |