oficina / calendar Public

Refuse booking availability for inverted gap occurrences

Paul Campbell pushed by paul@keymail.dev c63b4dcfb74d0f860445a74e8fb4c93283f80a95 parent 1a6dcd0
3 files changed, +117 −2
  • internal/calendar/occurrences.go +9 −2
  • internal/calendar/recurrence_gap_stopgap_test.go +52 −0
  • internal/calendartest/recurrence_gap_stopgap_test.go +56 −0
diff --git a/internal/calendar/occurrences.go b/internal/calendar/occurrences.go
index 27014a4..2ab500b 100644
--- a/internal/calendar/occurrences.go
+++ b/internal/calendar/occurrences.go
@@ -87,6 +87,7 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
var out []Occurrence
generated := 0
complete := false
+ uncertainWindow := false
for cur, i := start, 0; i < 100000; i++ {
if r.Count > 0 && generated >= r.Count {
complete = true
@@ -109,9 +110,15 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
key := occStart.Format(layout)
if !skip[key] {
+ invalidSpan := end.After(start) && eu <= su
+ if invalidSpan && eu < to && su >= from {
+ // Reversed gap endpoints cannot establish free time between
+ // them. Keep walking for later dates without rendering this chip.
+ uncertainWindow = true
+ }
// Half-open overlap on both sides: an event that ends
// exactly at the window's start is not in the window.
- if eu > from && su < to {
+ if !invalidSpan && eu > from && su < to {
out = append(out, Occurrence{Local: key, StartUTC: su, EndUTC: eu})
if len(out) >= MaxOccurrencesPerSeries {
// The window was reached, but not all of it was
@@ -139,7 +146,7 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
}
cur = next
}
- return out, complete && validInput
+ return out, complete && validInput && !uncertainWindow
}
// shiftDays puts proto's time of day onto a date dayspan days after cur.
diff --git a/internal/calendar/recurrence_gap_stopgap_test.go b/internal/calendar/recurrence_gap_stopgap_test.go
new file mode 100644
index 0000000..52037b1
--- /dev/null
+++ b/internal/calendar/recurrence_gap_stopgap_test.go
@@ -0,0 +1,52 @@
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestInvertedGapOccurrenceRefusesOnlyAffectedWindows(t *testing.T) {
+ r, err := ParseRule("FREQ=DAILY")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct {
+ name, from, to, exclude string
+ complete bool
+ count int
+ }{
+ {"gap", "2026-03-29T00:00:00Z", "2026-03-30T00:00:00Z", "", false, 0},
+ {"between-endpoints", "2026-03-29T01:10:00Z", "2026-03-29T01:20:00Z", "", false, 0},
+ {"resolved-start", "2026-03-29T01:30:00Z", "2026-03-29T02:00:00Z", "", false, 0},
+ {"before-gap", "2026-03-28T00:00:00Z", "2026-03-29T00:00:00Z", "", true, 1},
+ {"after-gap", "2026-04-01T00:00:00Z", "2026-04-02T00:00:00Z", "", true, 1},
+ {"excluded", "2026-03-29T00:00:00Z", "2026-03-30T00:00:00Z", "2026-03-29T02:30", true, 0},
+ {"walk-continues", "2026-03-29T00:00:00Z", "2026-03-31T00:00:00Z", "", false, 1},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ from, _ := time.Parse(time.RFC3339, c.from)
+ to, _ := time.Parse(time.RFC3339, c.to)
+ got, complete := ExpandComplete("2026-03-01T01:30", "2026-03-01T02:00", "Europe/Dublin", false, r, c.exclude, from.Unix(), to.Unix())
+ if complete != c.complete || len(got) != c.count {
+ t.Fatalf("got=%v complete=%v; want count=%d complete=%v", got, complete, c.count, c.complete)
+ }
+ for _, o := range got {
+ if o.EndUTC-o.StartUTC != 1800 {
+ t.Fatalf("ordinary occurrence changed: %+v", o)
+ }
+ }
+ })
+ }
+}
+
+func TestCollapsedGapOccurrenceIsIncomplete(t *testing.T) {
+ r, err := ParseRule("FREQ=DAILY;COUNT=2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ from := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC).Unix()
+ got, complete := ExpandComplete("2026-03-28T01:30", "2026-03-28T02:30", "Europe/Dublin", false, r, "", from, from+86400)
+ if complete || len(got) != 0 {
+ t.Fatalf("collapsed gap got=%v complete=%v", got, complete)
+ }
+}
diff --git a/internal/calendartest/recurrence_gap_stopgap_test.go b/internal/calendartest/recurrence_gap_stopgap_test.go
new file mode 100644
index 0000000..5e92ce4
--- /dev/null
+++ b/internal/calendartest/recurrence_gap_stopgap_test.go
@@ -0,0 +1,56 @@
+package calendartest
+
+import (
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+ "context"
+ "errors"
+ "net/url"
+ "testing"
+ "time"
+)
+
+func TestGapSeriesRefusesFreeSlotsWithoutBreakingOrdinaryDates(t *testing.T) {
+ app, session := signedIn(t, "gap@example.test")
+ createEvent(t, app.Handler(), session, url.Values{"title": {"Gap fixture"}, "start": {"2026-03-01T01:30"}, "end": {"2026-03-01T02:00"}, "tz": {"Europe/Dublin"}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}})
+ host := app.PersonByAddressForTest(t, "gap@example.test")
+ st := app.StoreForTest()
+ ctx := context.Background()
+ ws, problems := calendar.ParseAvailability("every day 0-4")
+ if len(problems) != 0 {
+ t.Fatal(problems)
+ }
+ stored, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{Title: "Gap check", Availability: "every day 0-4", DurationMin: 30, HorizonDays: 30}, ws)
+ if err != nil {
+ t.Fatal(err)
+ }
+ page, err := st.BookingPage(ctx, host.ID, stored.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct {
+ day int
+ month time.Month
+ incomplete bool
+ }{{29, time.March, true}, {27, time.March, false}, {1, time.April, false}} {
+ from := time.Date(2026, c.month, c.day, 0, 0, 0, 0, time.UTC)
+ to := from.Add(24 * time.Hour)
+ slots, err := st.FreeSlots(ctx, page, host, from, from, to, 0, 0)
+ if c.incomplete {
+ if !errors.Is(err, calendar.ErrIncompleteExpansion) || len(slots) != 0 {
+ t.Fatalf("gap FreeSlots=%v err=%v", slots, err)
+ }
+ } else if err != nil || len(slots) == 0 {
+ t.Fatalf("ordinary date %v incomplete", from)
+ }
+ views, err := st.EventsInRange(ctx, host.ID, from.Unix(), to.Unix())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.incomplete && len(views) != 0 {
+ t.Fatalf("invalid chip: %+v", views)
+ }
+ if !c.incomplete && len(views) != 1 {
+ t.Fatalf("ordinary date %v: %d views", from, len(views))
+ }
+ }
+}