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>
6 files changed,
+496
−140
internal/calendar/occurrences.go+23 −138internal/calendar/occurrences_step.go+166 −0internal/calendar/recur.go+21 −0internal/calendar/store_range.go+40 −2internal/calendartest/occurrence_strict_test.go+130 −0internal/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") |
| + } |
| +} |