oficina / calendar Public

recurrence: six engine defects the booking review found, fixed where every view already had them

A daily 01:30 Dublin meeting whose 29 March instance resolved into the
spring gap started at 02:30 for the rest of its life, because advance
stepped from the RESOLVED time. Every candidate is now rebuilt from the
series' original clock on the new civil date. A two-day event across
the transition was 47 hours and "one day", so later occurrences ended
a day early; spans are civil dates now. A fortnightly Monday-and-
Wednesday rule from a Wednesday never generated the Monday, because the
search looked seven days ahead and then jumped; it now walks every
selected weekday of each eligible week, anchored to the series' own
week. ParseRule accepted "every Monday of the month" and the expander
read it as "the 7th"; combinations the expander cannot run are foreign
now, stored verbatim, shown once. Expand ran out of steps on a 1700
rule and returned an ordinary empty slice; ExpandComplete says so, and
EventsInRangeStrict — the reader anything deciding FREE time must use —
fails closed on that or on a foreign busy rule. And the UNTIL exclusion
gains a 25-hour day of slack, because an occurrence's elapsed length is
not the master's across a DST day.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 433e5c1e2ab89d7c3690637a5f30069af94a5fc3 parent 49c3626
6 files changed, +496 −140
  • internal/calendar/occurrences.go +23 −138
  • internal/calendar/occurrences_step.go +166 −0
  • internal/calendar/recur.go +21 −0
  • internal/calendar/store_range.go +40 −2
  • internal/calendartest/occurrence_strict_test.go +130 −0
  • internal/calendartest/recur_engine_test.go +116 −0
diff --git a/internal/calendar/occurrences.go b/internal/calendar/occurrences.go
index f24fa07..e8f1243 100644
--- a/internal/calendar/occurrences.go
+++ b/internal/calendar/occurrences.go
@@ -45,13 +45,24 @@ type Occurrence struct {
// and the fifth Friday of a month happens only in months that have
// one.
func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string, from, to int64) []Occurrence {
+ out, _ := ExpandComplete(startLocal, endLocal, tz, allDay, r, exdates, from, to)
+ return out
+}
+
+// ExpandComplete is Expand plus the one fact Expand drops: whether the
+// walk REACHED the window. It stops after 100,000 steps, and a series
+// old enough to run out of steps first returns an ordinary slice with
+// nothing in it — which reads as "nothing on" to a view, and would
+// read as "free" to a booking page. Anything that decides free time
+// asks this form and fails closed on false (booking spec §4.1, B23).
+func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string, from, to int64) ([]Occurrence, bool) {
loc, err := Zone(tz)
if err != nil {
- return nil
+ return nil, true
}
start, _, err := ParseLocal(startLocal, loc)
if err != nil {
- return nil
+ return nil, true
}
end, _, err := ParseLocal(endLocal, loc)
if err != nil {
@@ -71,11 +82,14 @@ func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string
var out []Occurrence
generated := 0
+ complete := false
for cur, i := start, 0; i < 100000; i++ {
if r.Count > 0 && generated >= r.Count {
+ complete = true
break
}
if r.HasUntil && cur.After(untilLocal(r, loc)) {
+ complete = true
break
}
occStart := cur
@@ -93,156 +107,27 @@ func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string
if eu > from && su < to {
out = append(out, Occurrence{Local: key, StartUTC: su, EndUTC: eu})
if len(out) >= MaxOccurrencesPerSeries {
+ // The window was reached; the cap is about how
+ // much of it to return, not whether we got there.
+ complete = true
break
}
}
}
// Past the window with nothing left that could come back.
if su >= to {
+ complete = true
break
}
- next, ok := advance(cur, r, loc)
+ next, ok := advance(cur, start, r, loc)
if !ok {
+ complete = true
break
}
cur = next
}
- return out
-}
-
-// advance steps to the next candidate start in wall clock, skipping
-// dates a rule names but a month does not have.
-func advance(cur time.Time, r Rule, loc *time.Location) (time.Time, bool) {
- switch r.Freq {
- case FreqDaily:
- return cur.AddDate(0, 0, r.Interval), true
-
- case FreqWeekly:
- if len(r.ByDay) == 0 {
- return cur.AddDate(0, 0, 7*r.Interval), true
- }
- // Walk forward a day at a time to the next named weekday,
- // jumping a whole interval of weeks when the week runs out.
- // Bounded: a week has seven days.
- for step := 1; step <= 7; step++ {
- cand := cur.AddDate(0, 0, step)
- if !containsWeekday(r.ByDay, cand.Weekday()) {
- continue
- }
- // Interval counts WEEKS, so an occurrence in a skipped
- // week is not one.
- if r.Interval > 1 && weeksBetween(cur, cand)%r.Interval != 0 {
- continue
- }
- return cand, true
- }
- return cur.AddDate(0, 0, 7*r.Interval), true
-
- case FreqMonthly:
- // Try successive months and skip any that cannot hold the
- // named date — never clamp. Bounded so a rule that can never
- // land (there is none in this subset, but a future one might)
- // terminates instead of spinning.
- for step := 1; step <= 120; step++ {
- base := firstOfMonth(cur, loc).AddDate(0, step*r.Interval, 0)
- if d, ok := monthlyDate(base, r, cur, loc); ok && d.After(cur) {
- return d, true
- }
- }
- return time.Time{}, false
-
- case FreqYearly:
- for step := 1; step <= 40; step++ {
- cand := time.Date(cur.Year()+step*r.Interval, cur.Month(), cur.Day(),
- cur.Hour(), cur.Minute(), 0, 0, loc)
- // 29 February in a non-leap year rolls into 1 March, which
- // is a different day and therefore not an occurrence.
- if cand.Day() == cur.Day() && cand.Month() == cur.Month() {
- return cand, true
- }
- }
- return time.Time{}, false
- }
- return time.Time{}, false
-}
-
-// monthlyDate resolves a monthly rule inside the month base sits in.
-func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (time.Time, bool) {
- y, m := base.Year(), base.Month()
- hh, mm := proto.Hour(), proto.Minute()
-
- switch {
- case r.Ordinal != 0:
- return nthWeekday(y, m, r.Ordinal, r.OrdinalDay, hh, mm, loc)
- case r.MonthDay > 0:
- if r.MonthDay > daysInMonth(y, m) {
- return time.Time{}, false // no 31st this month: skip it
- }
- return time.Date(y, m, r.MonthDay, hh, mm, 0, 0, loc), true
- default:
- // No BY part: the same day-of-month as DTSTART.
- if proto.Day() > daysInMonth(y, m) {
- return time.Time{}, false
- }
- return time.Date(y, m, proto.Day(), hh, mm, 0, 0, loc), true
- }
-}
-
-// nthWeekday finds "the 2nd Friday" (n>0) or "the last Friday" (n<0) of
-// a month, reporting false when the month has no such day — a fifth
-// Friday exists in some months and not others.
-func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *time.Location) (time.Time, bool) {
- if n > 0 {
- first := time.Date(y, m, 1, hh, mm, 0, 0, loc)
- delta := (int(wd) - int(first.Weekday()) + 7) % 7
- day := 1 + delta + (n-1)*7
- if day > daysInMonth(y, m) {
- return time.Time{}, false
- }
- return time.Date(y, m, day, hh, mm, 0, 0, loc), true
- }
- last := time.Date(y, m, daysInMonth(y, m), hh, mm, 0, 0, loc)
- delta := (int(last.Weekday()) - int(wd) + 7) % 7
- day := last.Day() - delta + (n+1)*7
- if day < 1 {
- return time.Time{}, false
- }
- return time.Date(y, m, day, hh, mm, 0, 0, loc), true
-}
-
-func daysInMonth(y int, m time.Month) int {
- // Day 0 of the next month is the last day of this one.
- return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
-}
-
-func firstOfMonth(t time.Time, loc *time.Location) time.Time {
- return time.Date(t.Year(), t.Month(), 1, t.Hour(), t.Minute(), 0, 0, loc)
-}
-
-func containsWeekday(days []time.Weekday, wd time.Weekday) bool {
- for _, d := range days {
- if d == wd {
- return true
- }
- }
- return false
-}
-
-// weeksBetween counts calendar weeks between two days, for INTERVAL on
-// a weekly rule.
-func weeksBetween(a, b time.Time) int {
- da := a.AddDate(0, 0, -int(a.Weekday()))
- db := b.AddDate(0, 0, -int(b.Weekday()))
- return int(db.Sub(da).Hours() / (24 * 7))
-}
-
-// daysBetween is the whole-day span from a start to an end, used to
-// carry an event's length across occurrences in wall clock.
-func daysBetween(start, end time.Time) int {
- s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
- e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
- return int(e.Sub(s).Hours() / 24)
+ return out, complete
}
// shiftDays puts proto's time of day onto a date dayspan days after cur.
diff --git a/internal/calendar/occurrences_step.go b/internal/calendar/occurrences_step.go
new file mode 100644
index 0000000..44b1306
--- /dev/null
+++ b/internal/calendar/occurrences_step.go
@@ -0,0 +1,166 @@
+package calendar
+
+import "time"
+
+// The stepping half of the recurrence engine, split from occurrences.go
+// so neither file passes the 300-line cap. Expand (there) walks; these
+// decide where the next step lands.
+
+// advance steps to the next candidate start in wall clock, skipping
+// dates a rule names but a month does not have.
+//
+// Every candidate is rebuilt from the series' ORIGINAL clock — proto's
+// hour and minute on the new civil date — never from cur's. cur is the
+// previous occurrence as it RESOLVED, and on the spring-forward day a
+// 01:30 resolves to 02:30; stepping from that with AddDate would carry
+// 02:30 forward for the rest of the series' life while the record
+// still said 01:30 (booking spec §4.1, r7 f1).
+func advance(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, bool) {
+ switch r.Freq {
+ case FreqDaily:
+ return onDate(cur.AddDate(0, 0, r.Interval), proto, loc), true
+
+ case FreqWeekly:
+ if len(r.ByDay) == 0 {
+ return onDate(cur.AddDate(0, 0, 7*r.Interval), proto, loc), true
+ }
+ // Walk forward a day at a time to the next named weekday in an
+ // ELIGIBLE week. Eligibility is measured from the SERIES' own
+ // week, not from cur's: a fortnightly Monday-and-Wednesday
+ // rule that starts on a Wednesday has its next Monday twelve
+ // days later, and a search that only looked seven days ahead
+ // from the Wednesday, then jumped a whole interval, landed on
+ // the following Wednesday and never generated that Monday
+ // (r8 f1). Bounded: the next eligible week is at most interval
+ // weeks away, and a week has seven days.
+ for step := 1; step <= 7*r.Interval+7; step++ {
+ cand := cur.AddDate(0, 0, step)
+ if !containsWeekday(r.ByDay, cand.Weekday()) {
+ continue
+ }
+ if r.Interval > 1 && weeksBetween(proto, cand)%r.Interval != 0 {
+ continue
+ }
+ return onDate(cand, proto, loc), true
+ }
+ return time.Time{}, false
+
+ case FreqMonthly:
+ // Try successive months and skip any that cannot hold the
+ // named date — never clamp. Bounded so a rule that can never
+ // land (there is none in this subset, but a future one might)
+ // terminates instead of spinning.
+ for step := 1; step <= 120; step++ {
+ base := firstOfMonth(cur, loc).AddDate(0, step*r.Interval, 0)
+ if d, ok := monthlyDate(base, r, proto, loc); ok && d.After(cur) {
+ return d, true
+ }
+ }
+ return time.Time{}, false
+
+ case FreqYearly:
+ for step := 1; step <= 40; step++ {
+ cand := time.Date(cur.Year()+step*r.Interval, proto.Month(), proto.Day(),
+ proto.Hour(), proto.Minute(), 0, 0, loc)
+ // 29 February in a non-leap year rolls into 1 March, which
+ // is a different day and therefore not an occurrence.
+ if cand.Day() == proto.Day() && cand.Month() == proto.Month() {
+ return cand, true
+ }
+ }
+ return time.Time{}, false
+ }
+ return time.Time{}, false
+}
+
+// onDate puts proto's time of day onto d's civil date in loc.
+func onDate(d, proto time.Time, loc *time.Location) time.Time {
+ return time.Date(d.Year(), d.Month(), d.Day(), proto.Hour(), proto.Minute(), 0, 0, loc)
+}
+
+// monthlyDate resolves a monthly rule inside the month base sits in.
+func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (time.Time, bool) {
+ y, m := base.Year(), base.Month()
+ hh, mm := proto.Hour(), proto.Minute()
+
+ switch {
+ case r.Ordinal != 0:
+ return nthWeekday(y, m, r.Ordinal, r.OrdinalDay, hh, mm, loc)
+ case r.MonthDay > 0:
+ if r.MonthDay > daysInMonth(y, m) {
+ return time.Time{}, false // no 31st this month: skip it
+ }
+ return time.Date(y, m, r.MonthDay, hh, mm, 0, 0, loc), true
+ default:
+ // No BY part: the same day-of-month as DTSTART.
+ if proto.Day() > daysInMonth(y, m) {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, proto.Day(), hh, mm, 0, 0, loc), true
+ }
+}
+
+// nthWeekday finds "the 2nd Friday" (n>0) or "the last Friday" (n<0) of
+// a month, reporting false when the month has no such day — a fifth
+// Friday exists in some months and not others.
+func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *time.Location) (time.Time, bool) {
+ if n > 0 {
+ first := time.Date(y, m, 1, hh, mm, 0, 0, loc)
+ delta := (int(wd) - int(first.Weekday()) + 7) % 7
+ day := 1 + delta + (n-1)*7
+ if day > daysInMonth(y, m) {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, day, hh, mm, 0, 0, loc), true
+ }
+ last := time.Date(y, m, daysInMonth(y, m), hh, mm, 0, 0, loc)
+ delta := (int(last.Weekday()) - int(wd) + 7) % 7
+ day := last.Day() - delta + (n+1)*7
+ if day < 1 {
+ return time.Time{}, false
+ }
+ return time.Date(y, m, day, hh, mm, 0, 0, loc), true
+}
+
+func daysInMonth(y int, m time.Month) int {
+ // Day 0 of the next month is the last day of this one.
+ return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}
+
+func firstOfMonth(t time.Time, loc *time.Location) time.Time {
+ return time.Date(t.Year(), t.Month(), 1, t.Hour(), t.Minute(), 0, 0, loc)
+}
+
+func containsWeekday(days []time.Weekday, wd time.Weekday) bool {
+ for _, d := range days {
+ if d == wd {
+ return true
+ }
+ }
+ return false
+}
+
+// civil is t's calendar date with no zone at all: the same date at
+// UTC midnight. Arithmetic on two of these counts DAYS, whatever the
+// zone's offsets did in between — a week across the spring transition
+// is 167 elapsed hours and still seven days.
+func civil(t time.Time) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+}
+
+// weeksBetween counts calendar weeks (Sunday-started) between two days,
+// for INTERVAL on a weekly rule.
+func weeksBetween(a, b time.Time) int {
+ da := civil(a).AddDate(0, 0, -int(a.Weekday()))
+ db := civil(b).AddDate(0, 0, -int(b.Weekday()))
+ return int(db.Sub(da).Hours() / (24 * 7))
+}
+
+// daysBetween is the whole-day span from a start to an end, used to
+// carry an event's length across occurrences in wall clock. Civil
+// dates, never elapsed hours: 28 March 09:00 to 30 March 09:00 in
+// Dublin is 47 hours and TWO days, and dividing by 24 made it one, so
+// every later occurrence ended a day early (booking spec §4.1, r4 B2).
+func daysBetween(start, end time.Time) int {
+ return int(civil(end).Sub(civil(start)).Hours() / 24)
+}
diff --git a/internal/calendar/recur.go b/internal/calendar/recur.go
index e3ed5fd..e25e509 100644
--- a/internal/calendar/recur.go
+++ b/internal/calendar/recur.go
@@ -137,6 +137,27 @@ func ParseRule(s string) (Rule, error) {
if r.MonthDay > 0 && r.Ordinal != 0 {
return r, fmt.Errorf("%w: BYMONTHDAY and an ordinal BYDAY together", ErrForeignRule)
}
+ // A COMBINATION the expander cannot run is foreign too. Each part
+ // above is legal on its own; "every Monday of the month" is
+ // FREQ=MONTHLY with a plain BYDAY, and the expander reads a monthly
+ // rule by its day of the month — it would have booked the 7th of
+ // every month over a rule that says every Monday, silently (booking
+ // spec §4.1, r8 f2). Refusing here stores the rule verbatim, shows
+ // its first occurrence, and lets the strict reader fail closed.
+ switch r.Freq {
+ case FreqWeekly:
+ if r.MonthDay > 0 || r.Ordinal != 0 {
+ return r, fmt.Errorf("%w: a monthly shape on a weekly rule", ErrForeignRule)
+ }
+ case FreqMonthly:
+ if len(r.ByDay) > 0 {
+ return r, fmt.Errorf("%w: BYDAY without an ordinal on a monthly rule", ErrForeignRule)
+ }
+ default: // daily, yearly
+ if len(r.ByDay) > 0 || r.MonthDay > 0 || r.Ordinal != 0 {
+ return r, fmt.Errorf("%w: BYDAY or BYMONTHDAY on a %s rule", ErrForeignRule, strings.ToLower(r.Freq))
+ }
+ }
return r, nil
}
diff --git a/internal/calendar/store_range.go b/internal/calendar/store_range.go
index ba330bf..b214922 100644
--- a/internal/calendar/store_range.go
+++ b/internal/calendar/store_range.go
@@ -2,6 +2,7 @@ package calendar
import (
"context"
+ "errors"
"fmt"
"sort"
)
@@ -35,6 +36,30 @@ type eventRow struct {
// Skipping pass 2 is the classic vanishing-event bug; skipping the
// dedupe is the classic duplicated one.
func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]EventView, error) {
+ return s.eventsInRange(ctx, personID, from, to, false)
+}
+
+// ErrIncompleteExpansion says a busy series in the window could not be
+// expanded to completion — its rule is outside the subset, or it ran
+// out of steps before reaching the window — so the answer is not a
+// full account of the person's time.
+var ErrIncompleteExpansion = errors.New("calendar: a busy series could not be fully expanded")
+
+// EventsInRangeStrict is EventsInRange for anyone deciding FREE time.
+//
+// The tolerant reader renders a foreign rule as its first occurrence
+// and a truncated expansion as whatever it reached, because a view
+// that shows less is better than a view that shows nothing. A booking
+// page cannot afford either: an occurrence it does not see is a slot it
+// offers. So this returns ErrIncompleteExpansion for any master that
+// occupies time, is not cancelled, and either has a rule ParseRule
+// rejects or ran out of steps (booking spec §4.1, B23). The rule is
+// still stored verbatim, per ruling 4.
+func (s *Store) EventsInRangeStrict(ctx context.Context, personID, from, to int64) ([]EventView, error) {
+ return s.eventsInRange(ctx, personID, from, to, true)
+}
+
+func (s *Store) eventsInRange(ctx context.Context, personID, from, to int64, strict bool) ([]EventView, error) {
rows, err := s.rangeRows(ctx, personID, from, to)
if err != nil {
return nil, err
@@ -93,6 +118,9 @@ func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]
// occurrence, which is a real event on a real day.
rule, perr := ParseRule(r.RRule)
if perr != nil {
+ if strict && OccupiesTime(r.Kind) && r.Status != StatusCancelled {
+ return nil, ErrIncompleteExpansion
+ }
v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
if err != nil {
return nil, err
@@ -102,7 +130,11 @@ func (s *Store) EventsInRange(ctx context.Context, personID, from, to int64) ([]
}
continue
}
- for _, occ := range Expand(r.StartLocal, r.EndLocal, r.TZ, r.AllDay, rule, r.Exdates, from, to) {
+ occs, complete := ExpandComplete(r.StartLocal, r.EndLocal, r.TZ, r.AllDay, rule, r.Exdates, from, to)
+ if !complete && strict && OccupiesTime(r.Kind) && r.Status != StatusCancelled {
+ return nil, ErrIncompleteExpansion
+ }
+ for _, occ := range occs {
key := overrideKey(r.ID, occ.Local)
if ov, ok := overrides[key]; ok {
placed[key] = true
@@ -177,8 +209,14 @@ func (s *Store) rangeRows(ctx context.Context, personID, from, to int64) ([]even
-- "finished" is until_utc plus one occurrence's length: a
-- final occurrence that runs for four days is still in
-- progress two days after the last start the rule allows.
+ -- Plus a 25-hour day of slack, because an occurrence's
+ -- elapsed length is not the master's across a DST day: the
+ -- fold makes a four-hour night shift five hours long, and
+ -- a bound measured from the master would have excluded it
+ -- from a query that opened in its fifth hour. A day's extra
+ -- expansion is cheap; a lost occurrence is a double-booking.
OR (events.rrule <> '' AND events.start_utc < ?
- AND (events.until_utc IS NULL OR events.until_utc + (events.end_utc - events.start_utc) >= ?))
+ AND (events.until_utc IS NULL OR events.until_utc + (events.end_utc - events.start_utc) + 90000 >= ?))
`, from, to, to, from).
Scan(&rows).Error
if err != nil {
diff --git a/internal/calendartest/occurrence_strict_test.go b/internal/calendartest/occurrence_strict_test.go
new file mode 100644
index 0000000..74936eb
--- /dev/null
+++ b/internal/calendartest/occurrence_strict_test.go
@@ -0,0 +1,130 @@
+package calendartest
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// The strict reader is what free time is decided from. It fails closed
+// on a series it could not expand to completion OR could not expand at
+// all, while the tolerant reader every view uses keeps answering
+// (booking spec §4.1, B23).
+func TestTheStrictReaderFailsClosedWhereTheTolerantOneRenders(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+ st := app.StoreForTest()
+ host := app.PersonByAddressForTest(t, "someone@example.com")
+ ctx := context.Background()
+
+ // An ordinary series: both readers agree.
+ createEvent(t, h, session, url.Values{
+ "title": {"Standup"}, "start": {testAt(1, "09:00")}, "end": {testAt(1, "09:15")},
+ "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"},
+ })
+ from, to := window(t, 1, 3)
+ tolerant, err := st.EventsInRange(ctx, host.ID, from, to)
+ if err != nil {
+ t.Fatal(err)
+ }
+ strict, err := st.EventsInRangeStrict(ctx, host.ID, from, to)
+ if err != nil {
+ t.Fatalf("strict read of an ordinary series: %v", err)
+ }
+ if len(strict) != len(tolerant) || len(strict) == 0 {
+ t.Fatalf("strict %d events, tolerant %d, want equal and non-zero", len(strict), len(tolerant))
+ }
+
+ // A rule outside the subset, imported verbatim: the day page shows
+ // its first occurrence (ruling 4), the strict reader refuses.
+ rewriteRule(t, app, "Standup", "FREQ=MONTHLY;BYDAY=MO")
+ if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil {
+ t.Errorf("tolerant read with a foreign rule: %v", err)
+ }
+ if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) {
+ t.Errorf("strict read with a foreign busy rule: err = %v, want ErrIncompleteExpansion", err)
+ }
+
+ // A note-kind series with the same rule does not occupy time, so
+ // the strict reader does not care.
+ setKind(t, app, "Standup", calendar.KindNote)
+ if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); err != nil {
+ t.Errorf("strict read with a foreign NOTE rule: %v, want nil", err)
+ }
+
+ // A series that runs out of expansion steps before the window.
+ setKind(t, app, "Standup", calendar.KindMeeting)
+ rewriteRule(t, app, "Standup", "FREQ=DAILY")
+ if _, err := app.DBForTest().Exec(`UPDATE events SET start_local = '1700-01-01T09:00', end_local = '1700-01-01T09:15',
+ start_utc = ?, end_utc = ? WHERE rrule <> ''`, time.Date(1700, 1, 1, 9, 0, 0, 0, time.UTC).Unix(),
+ time.Date(1700, 1, 1, 9, 15, 0, 0, time.UTC).Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil {
+ t.Errorf("tolerant read of a 1700 series: %v", err)
+ }
+ if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) {
+ t.Errorf("strict read of a 1700 series: err = %v, want ErrIncompleteExpansion", err)
+ }
+}
+
+// The final occurrence of a series whose UNTIL is the night before the
+// fold is FIVE hours long that day, not four. A master-duration bound
+// would have excluded it from a query that opens after its fourth hour;
+// the day of slack keeps it (r4 A7).
+func TestTheUntilBoundHasADayOfSlack(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+ postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"},
+ "default_reminder_min": {"0"}, "default_all_day_min": {"0"}})
+
+ // Daily 00:00–04:00 from the Friday before the fold, ending Saturday
+ // 24 October — UNTIL is a bare date, inclusive of that day, so the
+ // Sunday 25 October instance is the one AFTER the last permitted
+ // start... unless the rule's last start is the 25th itself. Make it
+ // so: repeat_end = 25 October. Its occurrence runs 00:00–04:00 on
+ // the fold Sunday, which is five elapsed hours.
+ createEvent(t, h, session, url.Values{
+ "title": {"Night shift"}, "start": {"2026-10-23T00:00"}, "end": {"2026-10-23T04:00"},
+ "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"daily"}, "repeat_end": {"2026-10-25"},
+ })
+ body := getAs(t, h, session, "/day/2026-10-25").Body.String()
+ if !strings.Contains(body, "Night shift") {
+ t.Error("the final occurrence across the fold is missing from its own day")
+ }
+}
+
+func window(t *testing.T, fromDay, toDay int) (int64, int64) {
+ t.Helper()
+ from, err := calendar.ToUTC(testDay(fromDay), "UTC", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ to, err := calendar.ToUTC(testDay(toDay), "UTC", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return from, to
+}
+
+// rewriteRule changes a series' stored rule underneath the app, the way
+// an import of a verbatim foreign rule would leave it.
+func rewriteRule(t *testing.T, app *calendar.App, title, rrule string) {
+ t.Helper()
+ // Titles are sealed, so the row is found by being the only master.
+ if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ? WHERE rrule <> '' AND series_id IS NULL`, rrule); err != nil {
+ t.Fatalf("rewrite rule: %v", err)
+ }
+}
+
+func setKind(t *testing.T, app *calendar.App, title, kind string) {
+ t.Helper()
+ if _, err := app.DBForTest().Exec(`UPDATE events SET kind = ? WHERE rrule <> '' AND series_id IS NULL`, kind); err != nil {
+ t.Fatalf("set kind: %v", err)
+ }
+}
diff --git a/internal/calendartest/recur_engine_test.go b/internal/calendartest/recur_engine_test.go
new file mode 100644
index 0000000..d42eb41
--- /dev/null
+++ b/internal/calendartest/recur_engine_test.go
@@ -0,0 +1,116 @@
+package calendartest
+
+import (
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// Six defects in the recurrence engine, found by review of the booking
+// spec (its §4.1) and owned by it: a booking page decides FREE time from
+// what this engine says is busy, so a dropped occurrence is a
+// double-booking, not a cosmetic gap.
+
+// A daily 01:30 Dublin meeting whose 29 March instance falls in the
+// spring gap resolves to 02:30 that day. It must NOT start at 02:30 for
+// the rest of its life: advancement carries the ORIGINAL civil clock
+// (booking spec §4.1, r7 f1).
+func TestTheSpringGapDoesNotShiftLaterOccurrences(t *testing.T) {
+ got := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin",
+ "FREQ=DAILY", "", "2026-03-28", "2026-04-01")
+ want := []string{
+ "2026-03-28T01:30",
+ "2026-03-29T02:30", // the gap day ALONE resolves forward, as clock.go documents
+ "2026-03-30T01:30",
+ "2026-03-31T01:30",
+ }
+ assertKeys(t, got, want)
+
+ // And in September the instant is 01:30 IST, not 02:30.
+ sept := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin",
+ "FREQ=DAILY", "", "2026-09-10", "2026-09-11")
+ assertKeys(t, sept, []string{"2026-09-10T01:30"})
+}
+
+// A two-day event across the spring transition is 47 elapsed hours and
+// TWO civil days. Dividing hours by 24 made it one, so every later
+// occurrence ended a day early (r4 B2).
+func TestAMultiDaySpanIsCountedInCivilDays(t *testing.T) {
+ r, err := calendar.ParseRule("FREQ=WEEKLY")
+ if err != nil {
+ t.Fatal(err)
+ }
+ from, _ := calendar.ToUTC("2026-04-04", "Europe/Dublin", true)
+ to, _ := calendar.ToUTC("2026-04-07", "Europe/Dublin", true)
+ occs := calendar.Expand("2026-03-28T09:00", "2026-03-30T09:00", "Europe/Dublin", false, r, "", from, to)
+ if len(occs) != 1 {
+ t.Fatalf("got %d occurrences, want 1: %+v", len(occs), occs)
+ }
+ dublin, _ := time.LoadLocation("Europe/Dublin")
+ end := time.Unix(occs[0].EndUTC, 0).In(dublin)
+ if end.Format("2006-01-02T15:04") != "2026-04-06T09:00" {
+ t.Errorf("the second occurrence ends %s, want 2026-04-06T09:00 (two civil days later)", end.Format("2006-01-02T15:04"))
+ }
+}
+
+// Fortnightly on Monday and Wednesday from a Wednesday: the next active
+// week has BOTH days. The old search looked only seven days ahead, found
+// the intervening week ineligible, and jumped straight to the Wednesday
+// (r8 f1).
+func TestAFortnightlyRuleKeepsEveryWeekdayOfAnEligibleWeek(t *testing.T) {
+ got := expandLocal(t, "2026-09-02T09:00", "2026-09-02T09:30", "UTC",
+ "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE", "", "2026-09-01", "2026-10-01")
+ want := []string{
+ "2026-09-02T09:00",
+ "2026-09-14T09:00", "2026-09-16T09:00",
+ "2026-09-28T09:00", "2026-09-30T09:00",
+ }
+ assertKeys(t, got, want)
+}
+
+// A rule the parser can read but the expander cannot faithfully run is
+// FOREIGN, not a silent misreading (r8 f2). Every Monday of the month is
+// a real rule; this subset does not carry it, and must say so rather than
+// expand it as "the 7th of every month".
+func TestCombinationsTheExpanderCannotRunAreForeign(t *testing.T) {
+ for _, rule := range []string{
+ "FREQ=MONTHLY;BYDAY=MO",
+ "FREQ=DAILY;BYDAY=MO",
+ "FREQ=YEARLY;BYMONTHDAY=3",
+ "FREQ=WEEKLY;BYMONTHDAY=3",
+ "FREQ=DAILY;BYMONTHDAY=3",
+ } {
+ _, err := calendar.ParseRule(rule)
+ if err == nil || !calendar.IsForeignRule(err) {
+ t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err)
+ }
+ }
+ // And the shapes the subset DOES carry still parse.
+ for _, rule := range []string{
+ "FREQ=WEEKLY;BYDAY=MO,WE", "FREQ=MONTHLY;BYMONTHDAY=15", "FREQ=MONTHLY;BYDAY=2FR",
+ "FREQ=MONTHLY", "FREQ=DAILY;INTERVAL=3", "FREQ=YEARLY",
+ } {
+ if _, err := calendar.ParseRule(rule); err != nil {
+ t.Errorf("ParseRule(%q) refused a supported rule: %v", rule, err)
+ }
+ }
+}
+
+// Expand says when it did not finish. A forever-daily rule from 1700
+// runs out of steps before 2026 and contributes nothing to a window it
+// should fill; the tolerant reader keeps rendering, the flag is for
+// anyone deciding free time (r4 B5).
+func TestExpandReportsWhenItRanOutOfSteps(t *testing.T) {
+ r, _ := calendar.ParseRule("FREQ=DAILY")
+ from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC).Unix()
+ _, complete := calendar.ExpandComplete("1700-01-01T09:00", "1700-01-01T09:30", "UTC", false, r, "", from, to)
+ if complete {
+ t.Error("a 1700 daily rule expanded to 2026 reported complete")
+ }
+ _, complete = calendar.ExpandComplete("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to)
+ if !complete {
+ t.Error("an ordinary daily rule reported incomplete")
+ }
+}