booking: free slots — the host's windows minus everything the host can see
Pure arithmetic in ComputeSlots, with B17's four-part check: a slot is kept only if its start reads as minute m, its end reads as m+duration on the same local date, and both ends round-trip through the stored form. That refuses the hour that does not exist and the hour that happens twice without anyone reasoning about which way Go resolved them; the two Dublin Sundays are pinned to real unix numbers with the observed behaviour explained beside each. BusyFor reads everything the host can see, re-deriving all-day events from the occurrence's dates in the event's own zone and then the host's DayBounds, so a 9 September commitment blocks all of 9 September in Tokyo rather than the UTC day that starts nine hours into it. The existing Busy(kind) becomes OccupiesTime and the view's half-hour Slot becomes ClickSlot, so the booking page's nouns can be Busy and Slot. Written by a Task 3 agent that hit its session limit one command short of committing; adopted and gated here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+665
−8
internal/calendar/models_event.go+2 −2internal/calendar/slots.go+167 −1internal/calendar/store_slots.go+85 −0internal/calendar/summary.go+1 −1internal/calendar/viewdata.go+4 −4internal/calendartest/slots_busy_test.go+125 −0internal/calendartest/slots_dst_test.go+105 −0internal/calendartest/slots_test.go+176 −0
diff --git a/internal/calendar/models_event.go b/internal/calendar/models_event.go| index 49740b8..39a1624 100644 |
| --- a/internal/calendar/models_event.go |
| +++ b/internal/calendar/models_event.go |
| @@ -136,9 +136,9 @@ const ( |
| KindNote = "note" |
| ) |
| -// Busy reports whether a kind occupies time in the day's arithmetic. A |
| +// OccupiesTime reports whether a kind occupies time in the day's arithmetic. A |
| // note does not: a birthday does not make your Tuesday fuller. |
| -func Busy(kind string) bool { return kind != KindNote } |
| +func OccupiesTime(kind string) bool { return kind != KindNote } |
| const ( |
| StatusConfirmed = "confirmed" |
diff --git a/internal/calendar/slots.go b/internal/calendar/slots.go| index d519e6f..5ac04b7 100644 |
| --- a/internal/calendar/slots.go |
| +++ b/internal/calendar/slots.go |
| @@ -1,6 +1,10 @@ |
| package calendar |
| -import "time" |
| +import ( |
| + "fmt" |
| + "sort" |
| + "time" |
| +) |
| // Window is one weekly availability window: a weekday and a half-open |
| // span of minutes from that day's local midnight, [StartMin, EndMin). |
| @@ -14,3 +18,165 @@ type Window struct { |
| StartMin int |
| EndMin int |
| } |
| + |
| +// MinNotice (models_booking.go) is the floor under a page's notice: |
| +// however eager the host, nothing is offered that starts inside the next |
| +// ten minutes. A slot that begins while the booker is still reading the |
| +// page is a slot they cannot reach in time to confirm. |
| + |
| +// The bounds on a slot's length, in minutes. Both are refused rather |
| +// than clamped: a zero duration would step the candidate loop forever |
| +// inside the hold mutex (booking spec §4.0, r3 f1), and B18 makes the |
| +// same limits invariants of every booked event. |
| +const ( |
| + MinSlotMinutes = 1 |
| + MaxSlotMinutes = 480 |
| +) |
| + |
| +// Slot is one bookable span, with its start on the HOST's wall clock. |
| +// Slots are computed in the host's zone and rendered in the booker's |
| +// (B9), so Local is the number the arithmetic was done in and StartUTC |
| +// is the instant the booker's page converts. |
| +// |
| +// The view layer's half-hour click targets are ClickSlot; this is the |
| +// booking page's unit. |
| +type Slot struct { |
| + StartUTC int64 |
| + EndUTC int64 |
| + Local time.Time |
| +} |
| + |
| +// Busy is one half-open UTC span the host cannot be booked over. Its |
| +// name is the noun the spec uses for the list; whether a KIND occupies |
| +// time is OccupiesTime. |
| +type Busy struct { |
| + StartUTC int64 |
| + EndUTC int64 |
| +} |
| + |
| +// SlotRule is a booking page's knobs, resolved to plain values. |
| +type SlotRule struct { |
| + DurationMin int |
| + BufferMin int |
| + NoticeMin int |
| + HorizonDays int |
| + Zone string |
| + Windows []Window |
| +} |
| + |
| +// ComputeSlots is the pure half of the booking page: the host's windows |
| +// over the host-zone days in [from, to), minus everything busy, minus |
| +// anything the clock rules out (booking spec §4). |
| +// |
| +// - earliest = now + max(NoticeMin, MinNotice); latest = now in the |
| +// host's zone + HorizonDays. A slot is offered iff start >= earliest, |
| +// start >= from, start < to, end <= latest. `to` bounds the START, |
| +// so a window that runs to midnight is offered when `to` is that |
| +// midnight. |
| +// - Candidates step from each window's StartMin by DurationMin while |
| +// a whole slot still fits. Each is kept only if it is the same |
| +// length on the wall clock and the elapsed clock (B17): its start |
| +// reads as minute m, its end reads as minute m + DurationMin on the |
| +// same local date (or 1440), and both ends round-trip through the |
| +// stored form. That is what refuses the hour that does not exist |
| +// and the hour that happens twice, without anyone reasoning about |
| +// which way Go resolved them. |
| +// - The buffer pads the slot on both sides before the half-open |
| +// overlap test against busy. |
| +// |
| +// Returns ascending by start. Bad input is an error, never a list. |
| +func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) { |
| + if rule.DurationMin < MinSlotMinutes || rule.DurationMin > MaxSlotMinutes { |
| + return nil, fmt.Errorf("calendar: slot duration %d minutes is outside %d–%d", rule.DurationMin, MinSlotMinutes, MaxSlotMinutes) |
| + } |
| + loc, err := Zone(rule.Zone) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, w := range rule.Windows { |
| + if w.EndMin <= w.StartMin { |
| + 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) |
| + } |
| + } |
| + |
| + dur := time.Duration(rule.DurationMin) * time.Minute |
| + pad := time.Duration(rule.BufferMin) * time.Minute |
| + notice := time.Duration(rule.NoticeMin) * time.Minute |
| + if notice < MinNotice { |
| + notice = MinNotice |
| + } |
| + earliest := now.Add(notice) |
| + latest := now.In(loc).AddDate(0, 0, rule.HorizonDays) |
| + |
| + // Days are host-zone calendar days, stepped by AddDate on a local |
| + // midnight — never Add(24h). The first day is the later of `from` |
| + // and `earliest`, since nothing before either can be offered, and |
| + // the last is bounded by `to` and `latest` for the same reason: a |
| + // candidate on a later day fails one inequality or the other. |
| + lower := from |
| + if earliest.After(lower) { |
| + lower = earliest |
| + } |
| + first := lower.In(loc) |
| + day := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, loc) |
| + |
| + var out []Slot |
| + for ; day.Before(to) && !day.After(latest); day = day.AddDate(0, 0, 1) { |
| + for _, w := range rule.Windows { |
| + if w.Weekday != day.Weekday() { |
| + continue |
| + } |
| + for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin { |
| + start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc) |
| + end := start.Add(dur) |
| + if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { |
| + continue |
| + } |
| + if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { |
| + continue |
| + } |
| + if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { |
| + continue |
| + } |
| + if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { |
| + continue |
| + } |
| + out = append(out, Slot{StartUTC: start.Unix(), EndUTC: end.Unix(), Local: start}) |
| + } |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].StartUTC < out[j].StartUTC }) |
| + return out, nil |
| +} |
| + |
| +// wallIs reports whether t reads as minute m of day on the wall clock: |
| +// on day's own date, or — when m is 1440 — as 00:00 of the next date. |
| +// Both t and day are in the host's zone. |
| +func wallIs(t, day time.Time, m int) bool { |
| + want := day |
| + if m == 1440 { |
| + want = day.AddDate(0, 0, 1) |
| + } |
| + y, mo, d := t.Date() |
| + wy, wmo, wd := want.Date() |
| + return y == wy && mo == wmo && d == wd && t.Hour()*60+t.Minute() == m%1440 |
| +} |
| + |
| +// roundTrips reports whether t survives being stored: written in the |
| +// stored local form and read back through ToUTC, it names the same |
| +// instant. Ruling 1 makes that form the record, so a slot whose ends do |
| +// not round-trip would be saved as a different meeting. |
| +func roundTrips(t time.Time, zone string) bool { |
| + u, err := ToUTC(FormatLocal(t, false), zone, false) |
| + return err == nil && u == t.Unix() |
| +} |
| + |
| +// overlapsAny is the half-open overlap of [start, end) with any busy span. |
| +func overlapsAny(start, end int64, busy []Busy) bool { |
| + for _, b := range busy { |
| + if b.StartUTC < end && b.EndUTC > start { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/calendar/store_slots.go b/internal/calendar/store_slots.go| new file mode 100644 |
| index 0000000..d90ff6a |
| --- /dev/null |
| +++ b/internal/calendar/store_slots.go |
| @@ -0,0 +1,85 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "time" |
| +) |
| + |
| +// BusyFor is everything the host can see (B2) that occupies time, as |
| +// plain UTC spans, over [from, to) plus a day either side — so a buffer |
| +// can reach across the window's edge and a busy span that ends five |
| +// minutes before `from` still removes the first slot. |
| +// |
| +// It reads through EventsInRange, so a hold — a tentative event — is |
| +// busy for free, and so the two range-query fixes in §4.1 are what make |
| +// a moved occurrence and a long final occurrence count here. |
| +// |
| +// skip is the caller's exclusion set: the event being rescheduled, and |
| +// held bookings whose hold has lapsed but which the sweep has not yet |
| +// freed. This function does not know about bookings and does not build |
| +// that set. |
| +func (s *Store) BusyFor(ctx context.Context, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) { |
| + hostLoc, err := Zone(host.Zone) |
| + if err != nil { |
| + return nil, err |
| + } |
| + events, err := s.EventsInRange(ctx, host.ID, from-86400, to+86400) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []Busy |
| + for _, e := range events { |
| + if !OccupiesTime(e.Kind) || e.Status == StatusCancelled || skip[e.ID] { |
| + continue |
| + } |
| + if !e.AllDay { |
| + out = append(out, Busy{StartUTC: e.OccStartUTC, EndUTC: e.OccEndUTC}) |
| + continue |
| + } |
| + spans, err := allDayBusy(e, host.Zone, hostLoc) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, spans...) |
| + } |
| + return out, nil |
| +} |
| + |
| +// allDayBusy turns an all-day occurrence into one whole HOST day per |
| +// date it covers. |
| +// |
| +// An all-day event is dates, not instants, and its UTC columns are an |
| +// index at midnight in the event's own zone — UTC when it has none, |
| +// which is every all-day event the form makes. So the dates are |
| +// recovered in that zone, from the occurrence's start through the |
| +// second before its exclusive end, and each date is then re-read as |
| +// the host's day: a 9 September commitment blocks all of 9 September |
| +// in Tokyo, not the UTC day that starts nine hours into it. |
| +func allDayBusy(e EventView, hostZone string, hostLoc *time.Location) ([]Busy, error) { |
| + evLoc, err := Zone(e.TZ) |
| + if err != nil { |
| + // An imported zone this build cannot resolve: fall back to |
| + // UTC rather than drop the event. Dropping it would book the |
| + // host over it; a day of slack in the wrong direction is the |
| + // lesser harm. |
| + evLoc = time.UTC |
| + } |
| + first := time.Unix(e.OccStartUTC, 0).In(evLoc) |
| + last := time.Unix(e.OccEndUTC-1, 0).In(evLoc) |
| + firstDate := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, evLoc) |
| + lastDate := time.Date(last.Year(), last.Month(), last.Day(), 0, 0, 0, 0, evLoc) |
| + |
| + var out []Busy |
| + for d := firstDate; !d.After(lastDate); d = d.AddDate(0, 0, 1) { |
| + // The DATE, rebuilt in the host's zone before DayBounds sees |
| + // it. Handing DayBounds the event-zone midnight would let it |
| + // land on the previous date for a host west of the event. |
| + day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, hostLoc) |
| + start, end, err := DayBounds(day, hostZone) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, Busy{StartUTC: start, EndUTC: end}) |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/calendar/summary.go b/internal/calendar/summary.go| index e30c29d..69a0dff 100644 |
| --- a/internal/calendar/summary.go |
| +++ b/internal/calendar/summary.go |
| @@ -52,7 +52,7 @@ func SummariseDay(day time.Time, events []EventView, dayStart, dayEnd int64, zon |
| var busy []EventView |
| for _, e := range events { |
| - if Busy(e.Kind) && !e.AllDay { |
| + if OccupiesTime(e.Kind) && !e.AllDay { |
| busy = append(busy, e) |
| } |
| } |
diff --git a/internal/calendar/viewdata.go b/internal/calendar/viewdata.go| index 4e6ef34..ac0151e 100644 |
| --- a/internal/calendar/viewdata.go |
| +++ b/internal/calendar/viewdata.go |
| @@ -129,7 +129,7 @@ type DayPage struct { |
| // Slots are the half-hour click-targets that make "create at this |
| // time" work with no JavaScript. Every one is an ordinary link to a |
| // prefilled form. |
| - Slots []Slot |
| + Slots []ClickSlot |
| Hours []string |
| } |
| @@ -143,8 +143,8 @@ type PlacedChip struct { |
| WidthPct float64 |
| } |
| -// Slot is one empty half-hour, as a link. |
| -type Slot struct { |
| +// ClickSlot is one empty half-hour, as a link. |
| +type ClickSlot struct { |
| Href string |
| // Label is for screen readers — "Create an event at 09:30 on |
| @@ -204,7 +204,7 @@ func BuildDay(day time.Time, events []EventView, zone string, now int64, calenda |
| continue |
| } |
| endT := t.Add(slotMinutes * time.Minute) |
| - p.Slots = append(p.Slots, Slot{ |
| + p.Slots = append(p.Slots, ClickSlot{ |
| Href: fmt.Sprintf("/events/new?start=%s&end=%s&calendar=%d", |
| t.Format(LayoutLocal), endT.Format(LayoutLocal), calendarID), |
| Label: fmt.Sprintf("Create an event at %s on %s", t.Format("15:04"), local.Format("Monday 2 January")), |
diff --git a/internal/calendartest/slots_busy_test.go b/internal/calendartest/slots_busy_test.go| new file mode 100644 |
| index 0000000..a0d3f87 |
| --- /dev/null |
| +++ b/internal/calendartest/slots_busy_test.go |
| @@ -0,0 +1,125 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// BusyFor is "everything the host can see" (B2) reduced to UTC spans. |
| +// Driven through the real app: the host signs in, moves to Tokyo in |
| +// Settings, and creates events through the form, so what is asserted is |
| +// what the range query actually returns for the rows the form writes. |
| +func TestBusyForIsEverythingTheHostCanSee(t *testing.T) { |
| + app, session := signedIn(t, "host@example.com") |
| + h := app.Handler() |
| + ctx := context.Background() |
| + const tokyo = "Asia/Tokyo" |
| + |
| + rec := postAs(t, h, session, "/settings", url.Values{ |
| + "zone": {tokyo}, "week_start": {"1"}, "default_reminder_min": {"10"}, "default_all_day_min": {"360"}, |
| + }) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("saving settings: status %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + store := app.StoreForTest() |
| + host, ok, err := store.PersonByAddress(ctx, "host@example.com") |
| + if err != nil || !ok || host.Zone != tokyo { |
| + t.Fatalf("host after settings: %+v, %v, %v", host, ok, err) |
| + } |
| + |
| + // Every event is created in the host's zone, Tokyo, because that is |
| + // what the form does; the all-day ones carry no zone at all. |
| + mk := func(title, kind, status, start, end string, extra url.Values) int64 { |
| + t.Helper() |
| + form := url.Values{"title": {title}, "kind": {kind}, "status": {status}, "start": {start}, "end": {end}, "repeat": {"none"}} |
| + for k, v := range extra { |
| + form[k] = v |
| + } |
| + id, err := strconv.ParseInt(strings.TrimPrefix(createEvent(t, h, session, form), "/events/"), 10, 64) |
| + if err != nil { |
| + t.Fatalf("event id: %v", err) |
| + } |
| + return id |
| + } |
| + timed := mk("Standup", "meeting", "confirmed", testAt(1, "10:00"), testAt(1, "11:00"), nil) |
| + mk("Cancelled", "meeting", "cancelled", testAt(1, "12:00"), testAt(1, "13:00"), nil) |
| + mk("Bin day", "note", "confirmed", testAt(1, "14:00"), testAt(1, "15:00"), nil) |
| + held := mk("Hold", "meeting", "tentative", testAt(1, "16:00"), testAt(1, "17:00"), nil) |
| + mk("Away", "commitment", "confirmed", testDay(2), testDay(3), url.Values{"all_day": {"1"}}) |
| + mk("Retreat", "commitment", "confirmed", testDay(4), testDay(5), url.Values{ |
| + "all_day": {"1"}, "repeat": {"daily"}, "repeat_end": {testDay(6)}, |
| + }) |
| + |
| + from := utc(t, testDay(0), tokyo, true) |
| + to := utc(t, testDay(8), tokyo, true) |
| + got, err := store.BusyFor(ctx, host, from, to, nil) |
| + if err != nil { |
| + t.Fatalf("BusyFor: %v", err) |
| + } |
| + span := func(hhmm string) calendar.Busy { |
| + s := utc(t, testAt(1, hhmm), tokyo, false) |
| + return calendar.Busy{StartUTC: s, EndUTC: s + 3600} |
| + } |
| + day := func(n int) calendar.Busy { |
| + return calendar.Busy{StartUTC: utc(t, testDay(n), tokyo, true), EndUTC: utc(t, testDay(n+1), tokyo, true)} |
| + } |
| + |
| + if !has(got, span("10:00")) { |
| + t.Errorf("a confirmed timed meeting is not busy: %v", got) |
| + } |
| + if has(got, span("12:00")) { |
| + t.Error("a cancelled meeting is busy") |
| + } |
| + if has(got, span("14:00")) { |
| + t.Error("a note is busy — a birthday does not make the day fuller") |
| + } |
| + if !has(got, span("16:00")) { |
| + t.Error("a tentative event — a hold — is not busy") |
| + } |
| + // The empty-zone all-day event blocks the host's WHOLE Tokyo day, |
| + // not the UTC day its index columns name. |
| + if !has(got, day(2)) { |
| + t.Errorf("an all-day commitment does not block the host's Tokyo day: %v", got) |
| + } |
| + // The recurring one blocks each OCCURRENCE's date, not the master's. |
| + if !has(got, day(5)) || !has(got, day(6)) { |
| + t.Errorf("a daily all-day commitment does not block its later occurrences' days: %v", got) |
| + } |
| + |
| + // skip is the caller's exclusion: the event being rescheduled, and |
| + // holds that have lapsed but are not yet swept. |
| + got, err = store.BusyFor(ctx, host, from, to, map[int64]bool{timed: true, held: true}) |
| + if err != nil { |
| + t.Fatalf("BusyFor with skip: %v", err) |
| + } |
| + if has(got, span("10:00")) || has(got, span("16:00")) { |
| + t.Errorf("skipped events are still busy: %v", got) |
| + } |
| + if !has(got, day(2)) { |
| + t.Error("skipping two events removed an unrelated one") |
| + } |
| +} |
| + |
| +func utc(t *testing.T, local, zone string, allDay bool) int64 { |
| + t.Helper() |
| + u, err := calendar.ToUTC(local, zone, allDay) |
| + if err != nil { |
| + t.Fatalf("ToUTC(%q, %q): %v", local, zone, err) |
| + } |
| + return u |
| +} |
| + |
| +func has(busy []calendar.Busy, want calendar.Busy) bool { |
| + for _, b := range busy { |
| + if b == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/calendartest/slots_dst_test.go b/internal/calendartest/slots_dst_test.go| new file mode 100644 |
| index 0000000..d6afef6 |
| --- /dev/null |
| +++ b/internal/calendartest/slots_dst_test.go |
| @@ -0,0 +1,105 @@ |
| +package calendartest |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// The two Sundays a year that break slot pages, with a 00:00–04:00 |
| +// window laid straight across the transition. Europe/Dublin 2026: DST |
| +// starts 29 March and ends 25 October, both at 01:00 UTC. |
| +// |
| +// As in clock_test.go, this does not assert correctness — there is no |
| +// correct answer for an hour that does not exist or happens twice, and |
| +// Go promises none. It asserts B17's four-part check holds of every |
| +// slot that IS offered, and then pins THE LIST, so a Go or tzdata |
| +// change that moves it fails here rather than double-booking somebody. |
| + |
| +// dstSunday computes one Dublin Sunday's 00:00–04:00 hourly slots, from |
| +// a clock at the Saturday before. |
| +func dstSunday(t *testing.T, y int, mo time.Month, d int) []calendar.Slot { |
| + t.Helper() |
| + day := time.Date(y, mo, d, 0, 0, 0, 0, zoneOf(t, dublin)) |
| + rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: dublin, |
| + Windows: []calendar.Window{{Weekday: time.Sunday, StartMin: 0, EndMin: 4 * 60}}} |
| + return compute(t, rule, nil, day.AddDate(0, 0, -1), day, day.AddDate(0, 0, 1)) |
| +} |
| + |
| +// honest asserts B17 of every slot: nothing straddles the transition, |
| +// both ends round-trip through the stored form, and elapsed length is |
| +// exactly the duration. |
| +func honest(t *testing.T, slots []calendar.Slot, transitionUTC int64) { |
| + t.Helper() |
| + loc := zoneOf(t, dublin) |
| + for _, s := range slots { |
| + if s.StartUTC < transitionUTC && s.EndUTC > transitionUTC { |
| + t.Errorf("slot %v straddles the transition at %d", s.Local, transitionUTC) |
| + } |
| + if s.EndUTC-s.StartUTC != 3600 { |
| + t.Errorf("slot %v is %d elapsed seconds, want 3600", s.Local, s.EndUTC-s.StartUTC) |
| + } |
| + if s.Local.Unix() != s.StartUTC { |
| + t.Errorf("slot Local %v is not its own StartUTC %d", s.Local, s.StartUTC) |
| + } |
| + for _, u := range []int64{s.StartUTC, s.EndUTC} { |
| + back, err := calendar.ToUTC(calendar.FormatLocal(time.Unix(u, 0).In(loc), false), dublin, false) |
| + if err != nil || back != u { |
| + t.Errorf("slot %v: %d does not round-trip through its wall clock (got %d, %v)", s.Local, u, back, err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func pinned(t *testing.T, slots []calendar.Slot, want ...int64) { |
| + t.Helper() |
| + got := make([]int64, 0, len(slots)) |
| + for _, s := range slots { |
| + got = append(got, s.StartUTC) |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("starts = %v, want %v", got, want) |
| + } |
| + for i := range got { |
| + if got[i] != want[i] { |
| + t.Fatalf("starts = %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +// Spring forward: 01:00 GMT jumps to 02:00 IST at 1774746000. |
| +// |
| +// What Go does, observed: time.Date resolves a wall time inside the gap |
| +// with the offset in force BEFORE the transition, so "01:00" lands at |
| +// 01:00 UTC and reads back as 02:00. Hence: |
| +// |
| +// - 00:00 (m=0) exists, but its END is the gap's "01:00", which reads |
| +// back as 02:00 — wall minute 120, not 60 — so it is refused. |
| +// - 01:00 (m=60) is itself in the gap and reads back as 02:00: refused. |
| +// - 02:00 IST = 1774746000 and 03:00 IST = 1774749600 are ordinary |
| +// hours on both clocks: offered. |
| +func TestSpringForwardSundayOffersOnlyHonestHours(t *testing.T) { |
| + slots := dstSunday(t, 2026, time.March, 29) |
| + honest(t, slots, 1774746000) |
| + pinned(t, slots, 1774746000, 1774749600) |
| +} |
| + |
| +// Fall back: 02:00 IST returns to 01:00 GMT at 1792890000, so the wall |
| +// clock reads 01:xx twice. |
| +// |
| +// What Go does, observed: an ambiguous wall time resolves to the SECOND |
| +// occurrence (clock.go, TestDSTGapAndFold). Hence: |
| +// |
| +// - 00:00 IST (m=0, 1792882800) exists, and its end 1792886400 is the |
| +// FIRST 01:00 — but "01:00" round-trips to the second one, an hour |
| +// later, so the stored form would name a different instant: refused. |
| +// This is B17's "two-hour hour". |
| +// - 01:00 (m=60) resolves to the second 01:00, 01:00 GMT = 1792890000, |
| +// a plain hour to 02:00 GMT: offered. |
| +// - 02:00 GMT = 1792893600 and 03:00 GMT = 1792897200: offered. |
| +func TestFallBackSundayOffersOnlyHonestHours(t *testing.T) { |
| + slots := dstSunday(t, 2026, time.October, 25) |
| + honest(t, slots, 1792890000) |
| + pinned(t, slots, 1792890000, 1792893600, 1792897200) |
| +} |
diff --git a/internal/calendartest/slots_test.go b/internal/calendartest/slots_test.go| new file mode 100644 |
| index 0000000..fe47ab6 |
| --- /dev/null |
| +++ b/internal/calendartest/slots_test.go |
| @@ -0,0 +1,176 @@ |
| +package calendartest |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// The slot arithmetic is pure, so it is tested as arithmetic: a rule, a |
| +// busy list, a clock, and the list that comes out. Everything here is |
| +// in Europe/Dublin, on Monday 7 September 2026 — a plain week with no |
| +// transition in it — except the two Sundays that are about nothing else. |
| +const dublin = "Europe/Dublin" |
| + |
| +func zoneOf(t *testing.T, name string) *time.Location { |
| + t.Helper() |
| + loc, err := calendar.Zone(name) |
| + if err != nil { |
| + t.Fatalf("Zone(%q): %v", name, err) |
| + } |
| + return loc |
| +} |
| + |
| +// mon is local midnight on Monday 7 September 2026 in Dublin. |
| +func mon(t *testing.T) time.Time { return time.Date(2026, 9, 7, 0, 0, 0, 0, zoneOf(t, dublin)) } |
| + |
| +// at is hh:mm on the same local date as day, in day's zone. |
| +func at(day time.Time, hh, mm int) time.Time { |
| + return time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, day.Location()) |
| +} |
| + |
| +// mondayRule offers 12:00–15:00 every Monday, in Dublin, a week ahead. |
| +func mondayRule(dur, buffer int) calendar.SlotRule { |
| + return calendar.SlotRule{ |
| + DurationMin: dur, BufferMin: buffer, NoticeMin: 0, HorizonDays: 7, Zone: dublin, |
| + Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}, |
| + } |
| +} |
| + |
| +func starts(slots []calendar.Slot) []string { |
| + out := make([]string, 0, len(slots)) |
| + for _, s := range slots { |
| + out = append(out, s.Local.Format("Mon 15:04")) |
| + } |
| + return out |
| +} |
| + |
| +func eq(t *testing.T, got []string, want ...string) { |
| + t.Helper() |
| + if len(got) != len(want) { |
| + t.Fatalf("slots = %v, want %v", got, want) |
| + } |
| + for i := range got { |
| + if got[i] != want[i] { |
| + t.Fatalf("slots = %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +func busy(a, b time.Time) calendar.Busy { return calendar.Busy{StartUTC: a.Unix(), EndUTC: b.Unix()} } |
| + |
| +// compute runs ComputeSlots over one Monday, from a clock at 08:00. |
| +func compute(t *testing.T, rule calendar.SlotRule, b []calendar.Busy, now, from, to time.Time) []calendar.Slot { |
| + t.Helper() |
| + slots, err := calendar.ComputeSlots(rule, b, now, from, to) |
| + if err != nil { |
| + t.Fatalf("ComputeSlots: %v", err) |
| + } |
| + return slots |
| +} |
| + |
| +func TestSlotsStepByTheirOwnDuration(t *testing.T) { |
| + m := mon(t) |
| + got := compute(t, mondayRule(60, 0), nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 12:00", "Mon 13:00", "Mon 14:00") |
| + for _, s := range got { |
| + if s.EndUTC-s.StartUTC != 3600 { |
| + t.Errorf("slot %v is %d seconds long, want 3600", s.Local, s.EndUTC-s.StartUTC) |
| + } |
| + } |
| +} |
| + |
| +func TestABusySpanRemovesEverySlotItTouches(t *testing.T) { |
| + m := mon(t) |
| + b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} |
| + got := compute(t, mondayRule(60, 0), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 12:00", "Mon 14:00") |
| +} |
| + |
| +// A buffer pads the SLOT, on both sides, before the overlap test. |
| +func TestABufferPadsBothSidesOfTheSlot(t *testing.T) { |
| + m := mon(t) |
| + b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} |
| + got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 14:00") |
| +} |
| + |
| +// Busy time ending five minutes before `from` is outside the window and |
| +// still removes the first buffered slot: that is why BusyFor fetches a |
| +// day either side. |
| +func TestBusyEndingBeforeFromStillRemovesABufferedSlot(t *testing.T) { |
| + m := mon(t) |
| + b := []calendar.Busy{busy(at(m, 11, 50), at(m, 11, 55))} |
| + got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), at(m, 12, 0), m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| +} |
| + |
| +// NoticeMin 0 does not mean "bookable this second": the floor is ten |
| +// minutes, so at 11:55 the 12:00 slot is gone. |
| +func TestNoticeHasATenMinuteFloor(t *testing.T) { |
| + if calendar.MinNotice != 10*time.Minute { |
| + t.Fatalf("MinNotice = %v, want 10m", calendar.MinNotice) |
| + } |
| + m := mon(t) |
| + got := compute(t, mondayRule(60, 0), nil, at(m, 11, 55), m, m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| +} |
| + |
| +// The horizon bounds the END of a slot, from `now` in the host's zone; |
| +// notice bounds the start. At Monday 11:00 with two hours' notice and a |
| +// seven-day horizon, this Monday keeps 13:00 and 14:00 and next Monday |
| +// offers nothing: its 12:00 slot would end at 13:00, past 11:00 + 7d. |
| +func TestNoticeAndHorizonBoundTheRun(t *testing.T) { |
| + m := mon(t) |
| + rule := mondayRule(60, 0) |
| + rule.NoticeMin = 120 |
| + got := compute(t, rule, nil, at(m, 11, 0), m, m.AddDate(0, 0, 14)) |
| + eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| +} |
| + |
| +// `to` bounds the START. A 23:00–24:00 window is offered when `to` is |
| +// the next local midnight, even though the slot ENDS exactly at `to`. |
| +func TestToBoundsTheStartNotTheEnd(t *testing.T) { |
| + m := mon(t) |
| + rule := mondayRule(60, 0) |
| + rule.Windows = []calendar.Window{{Weekday: time.Monday, StartMin: 23 * 60, EndMin: 24 * 60}} |
| + got := compute(t, rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 23:00") |
| + if end := time.Unix(got[0].EndUTC, 0).In(m.Location()); !end.Equal(m.AddDate(0, 0, 1)) { |
| + t.Errorf("the 23:00 slot ends at %v, want the next midnight", end) |
| + } |
| +} |
| + |
| +// Slots are computed in the HOST's zone and only rendered in the |
| +// booker's: a Tokyo Monday morning is a Los Angeles Sunday afternoon. |
| +func TestATokyoMondayIsALosAngelesSunday(t *testing.T) { |
| + tokyo := zoneOf(t, "Asia/Tokyo") |
| + day := time.Date(2026, 9, 7, 0, 0, 0, 0, tokyo) |
| + rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: "Asia/Tokyo", |
| + Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 9 * 60, EndMin: 10 * 60}}} |
| + got := compute(t, rule, nil, at(day, 1, 0), day, day.AddDate(0, 0, 1)) |
| + eq(t, starts(got), "Mon 09:00") |
| + la := time.Unix(got[0].StartUTC, 0).In(zoneOf(t, "America/Los_Angeles")) |
| + if la.Weekday() != time.Sunday || la.Hour() != 17 { |
| + t.Errorf("Tokyo Monday 09:00 renders in Los Angeles as %v, want Sunday 17:00", la) |
| + } |
| +} |
| + |
| +// Bad input is an error, never a list, and never a loop: a zero |
| +// duration is the one that would spin forever inside the hold mutex. |
| +func TestBadRulesAreErrorsNotLists(t *testing.T) { |
| + m := mon(t) |
| + for name, rule := range map[string]calendar.SlotRule{ |
| + "zero duration": {DurationMin: 0, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, |
| + "481 minutes": {DurationMin: 481, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, |
| + "unknown zone": {DurationMin: 60, HorizonDays: 7, Zone: "Mars/Olympus_Mons", Windows: mondayRule(60, 0).Windows}, |
| + "inverted window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 15 * 60, EndMin: 12 * 60}}}, |
| + "empty window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 12 * 60}}}, |
| + } { |
| + slots, err := calendar.ComputeSlots(rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| + if err == nil || slots != nil { |
| + t.Errorf("%s: got %v, %v; want an error and no list", name, slots, err) |
| + } |
| + } |
| +} |