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 "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.
16type 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.
31const (
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.
43type 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.
52type Busy struct {
53 StartUTC int64
54 EndUTC int64
55}
56
57// SlotRule is a booking page's knobs, resolved to plain values.
58type 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.
88func 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.
155func 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.
169func 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.
175func 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