oficina / calendar Public

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>
Paul Campbell pushed by paul@keymail.dev 313abaee65c9d09e2900a66c257dc55f9af3ae43 parent 913aaf3
8 files changed, +665 −8
  • internal/calendar/models_event.go +2 −2
  • internal/calendar/slots.go +167 −1
  • internal/calendar/store_slots.go +85 −0
  • internal/calendar/summary.go +1 −1
  • internal/calendar/viewdata.go +4 −4
  • internal/calendartest/slots_busy_test.go +125 −0
  • internal/calendartest/slots_dst_test.go +105 −0
  • internal/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)
+ }
+ }
+}