| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // Window is one weekly availability window: a weekday and a half-open |
| 10 | // span of minutes from that day's local midnight, [StartMin, EndMin). |
| 11 | // |
| 12 | // It is the plain value the availability parser produces and the slot |
| 13 | // arithmetic consumes, and the value BookingWindow rows are read into. |
| 14 | // Declared here, in the file that does the arithmetic, so that neither |
| 15 | // the parser nor the store has to be compiled to talk about one. |
| 16 | type Window struct { |
| 17 | Weekday time.Weekday |
| 18 | StartMin int |
| 19 | EndMin int |
| 20 | } |
| 21 | |
| 22 | // MinNotice (models_booking.go) is the floor under a page's notice: |
| 23 | // however eager the host, nothing is offered that starts inside the next |
| 24 | // ten minutes. A slot that begins while the booker is still reading the |
| 25 | // page is a slot they cannot reach in time to confirm. |
| 26 | |
| 27 | // The bounds on a slot's length, in minutes. Both are refused rather |
| 28 | // than clamped: a zero duration would step the candidate loop forever |
| 29 | // inside the hold mutex (booking spec §4.0, r3 f1), and B18 makes the |
| 30 | // same limits invariants of every booked event. |
| 31 | const ( |
| 32 | MinSlotMinutes = 1 |
| 33 | MaxSlotMinutes = 480 |
| 34 | ) |
| 35 | |
| 36 | // Slot is one bookable span, with its start on the HOST's wall clock. |
| 37 | // Slots are computed in the host's zone and rendered in the booker's |
| 38 | // (B9), so Local is the number the arithmetic was done in and StartUTC |
| 39 | // is the instant the booker's page converts. |
| 40 | // |
| 41 | // The view layer's half-hour click targets are ClickSlot; this is the |
| 42 | // booking page's unit. |
| 43 | type Slot struct { |
| 44 | StartUTC int64 |
| 45 | EndUTC int64 |
| 46 | Local time.Time |
| 47 | } |
| 48 | |
| 49 | // Busy is one half-open UTC span the host cannot be booked over. Its |
| 50 | // name is the noun the spec uses for the list; whether a KIND occupies |
| 51 | // time is OccupiesTime. |
| 52 | type Busy struct { |
| 53 | StartUTC int64 |
| 54 | EndUTC int64 |
| 55 | } |
| 56 | |
| 57 | // SlotRule is a booking page's knobs, resolved to plain values. |
| 58 | type SlotRule struct { |
| 59 | DurationMin int |
| 60 | BufferMin int |
| 61 | NoticeMin int |
| 62 | HorizonDays int |
| 63 | Zone string |
| 64 | Windows []Window |
| 65 | } |
| 66 | |
| 67 | // ComputeSlots is the pure half of the booking page: the host's windows |
| 68 | // over the host-zone days in [from, to), minus everything busy, minus |
| 69 | // anything the clock rules out (booking spec §4). |
| 70 | // |
| 71 | // - earliest = now + max(NoticeMin, MinNotice); latest = now in the |
| 72 | // host's zone + HorizonDays. A slot is offered iff start >= earliest, |
| 73 | // start >= from, start < to, end <= latest. `to` bounds the START, |
| 74 | // so a window that runs to midnight is offered when `to` is that |
| 75 | // midnight. |
| 76 | // - Candidates step from each window's StartMin by DurationMin while |
| 77 | // a whole slot still fits. Each is kept only if it is the same |
| 78 | // length on the wall clock and the elapsed clock (B17): its start |
| 79 | // reads as minute m, its end reads as minute m + DurationMin on the |
| 80 | // same local date (or 1440), and both ends round-trip through the |
| 81 | // stored form. That is what refuses the hour that does not exist |
| 82 | // and the hour that happens twice, without anyone reasoning about |
| 83 | // which way Go resolved them. |
| 84 | // - The buffer pads the slot on both sides before the half-open |
| 85 | // overlap test against busy. |
| 86 | // |
| 87 | // Returns ascending by start. Bad input is an error, never a list. |
| 88 | func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) { |
| 89 | if rule.DurationMin < MinSlotMinutes || rule.DurationMin > MaxSlotMinutes { |
| 90 | return nil, fmt.Errorf("calendar: slot duration %d minutes is outside %d–%d", rule.DurationMin, MinSlotMinutes, MaxSlotMinutes) |
| 91 | } |
| 92 | loc, err := Zone(rule.Zone) |
| 93 | if err != nil { |
| 94 | return nil, err |
| 95 | } |
| 96 | for _, w := range rule.Windows { |
| 97 | if w.EndMin <= w.StartMin { |
| 98 | return nil, fmt.Errorf("calendar: slot window on %s ends at minute %d, at or before its start at %d", w.Weekday, w.EndMin, w.StartMin) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | dur := time.Duration(rule.DurationMin) * time.Minute |
| 103 | pad := time.Duration(rule.BufferMin) * time.Minute |
| 104 | notice := time.Duration(rule.NoticeMin) * time.Minute |
| 105 | if notice < MinNotice { |
| 106 | notice = MinNotice |
| 107 | } |
| 108 | earliest := now.Add(notice) |
| 109 | latest := now.In(loc).AddDate(0, 0, rule.HorizonDays) |
| 110 | |
| 111 | // Days are host-zone calendar days, stepped by AddDate on a local |
| 112 | // midnight — never Add(24h). The first day is the later of `from` |
| 113 | // and `earliest`, since nothing before either can be offered, and |
| 114 | // the last is bounded by `to` and `latest` for the same reason: a |
| 115 | // candidate on a later day fails one inequality or the other. |
| 116 | lower := from |
| 117 | if earliest.After(lower) { |
| 118 | lower = earliest |
| 119 | } |
| 120 | first := lower.In(loc) |
| 121 | day := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, loc) |
| 122 | |
| 123 | var out []Slot |
| 124 | for ; day.Before(to) && !day.After(latest); day = day.AddDate(0, 0, 1) { |
| 125 | for _, w := range rule.Windows { |
| 126 | if w.Weekday != day.Weekday() { |
| 127 | continue |
| 128 | } |
| 129 | for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin { |
| 130 | start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc) |
| 131 | end := start.Add(dur) |
| 132 | if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { |
| 133 | continue |
| 134 | } |
| 135 | if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { |
| 136 | continue |
| 137 | } |
| 138 | if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { |
| 139 | continue |
| 140 | } |
| 141 | if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { |
| 142 | continue |
| 143 | } |
| 144 | out = append(out, Slot{StartUTC: start.Unix(), EndUTC: end.Unix(), Local: start}) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | sort.Slice(out, func(i, j int) bool { return out[i].StartUTC < out[j].StartUTC }) |
| 149 | return out, nil |
| 150 | } |
| 151 | |
| 152 | // wallIs reports whether t reads as minute m of day on the wall clock: |
| 153 | // on day's own date, or — when m is 1440 — as 00:00 of the next date. |
| 154 | // Both t and day are in the host's zone. |
| 155 | func wallIs(t, day time.Time, m int) bool { |
| 156 | want := day |
| 157 | if m == 1440 { |
| 158 | want = day.AddDate(0, 0, 1) |
| 159 | } |
| 160 | y, mo, d := t.Date() |
| 161 | wy, wmo, wd := want.Date() |
| 162 | return y == wy && mo == wmo && d == wd && t.Hour()*60+t.Minute() == m%1440 |
| 163 | } |
| 164 | |
| 165 | // roundTrips reports whether t survives being stored: written in the |
| 166 | // stored local form and read back through ToUTC, it names the same |
| 167 | // instant. Ruling 1 makes that form the record, so a slot whose ends do |
| 168 | // not round-trip would be saved as a different meeting. |
| 169 | func roundTrips(t time.Time, zone string) bool { |
| 170 | u, err := ToUTC(FormatLocal(t, false), zone, false) |
| 171 | return err == nil && u == t.Unix() |
| 172 | } |
| 173 | |
| 174 | // overlapsAny is the half-open overlap of [start, end) with any busy span. |
| 175 | func overlapsAny(start, end int64, busy []Busy) bool { |
| 176 | for _, b := range busy { |
| 177 | if b.StartUTC < end && b.EndUTC > start { |
| 178 | return true |
| 179 | } |
| 180 | } |
| 181 | return false |
| 182 | } |
| 183 | |