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 "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.
23func (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).
30func (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.
67func 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