Refuse booking availability for inverted gap occurrences
3 files changed,
+117
−2
internal/calendar/occurrences.go+9 −2internal/calendar/recurrence_gap_stopgap_test.go+52 −0internal/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)) |
| + } |
| + } |
| +} |