oficina / calendar Public

Merge branch 'fix/release-repairs' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 7b0d4b7921194db6988365ab84e8c499cc2125fa parent 9400eb0parent 10212c5
19 files changed, +1009 −75
  • AGENTS.md +2 −0
  • docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md +81 −0
  • internal/calendar/app.go +5 −0
  • internal/calendar/key_check.go +49 −0
  • internal/calendar/occurrences.go +28 −10
  • internal/calendar/occurrences_bounds.go +57 −0
  • internal/calendar/occurrences_step.go +70 −58
  • internal/calendar/recurrence_bounds_probe_test.go +84 −0
  • internal/calendar/recurrence_gap_stopgap_test.go +52 −0
  • internal/calendar/recurrence_horizon_test.go +107 −0
  • internal/calendar/release_repairs_test.go +100 −0
  • internal/calendar/store_event_write.go +2 −3
  • internal/calendar/store_feed.go +1 −1
  • internal/calendar/store_feed_rows.go +72 −0
  • internal/calendar/store_override.go +5 −2
  • internal/calendartest/booking_review2_test.go +13 −1
  • internal/calendartest/edit_reminder_reschedule_test.go +195 −0
  • internal/calendartest/recurrence_gap_stopgap_test.go +56 −0
  • internal/calendartest/recurrence_invalid_test.go +30 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 7561bf2..a54894b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -259,3 +259,5 @@ reviewed. A review by a weaker model than the author is not a review.
🤖 / 👨 markers, `Co-Authored-By:` trailers. AI is a guest with a name
tag: opt-in, disclosed, and every path works with it switched off.
+
+Startup verifies the configured data key against `meta.sealed_key_check_v1`, or existing encrypted calendar names on first adoption. A refusal requires restoring the correct key/database pair. Never delete or replace the sentinel to make an unreadable database start.
diff --git a/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md b/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md
new file mode 100644
index 0000000..e1a1346
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md
@@ -0,0 +1,81 @@
+# Recurrence stepping bounds
+
+Status: revision 2 APPROVED by Claude Fable5.1 (claude-fable-5-1), 2026-09-07.
+Evidence: /tmp/oficina-calendar-recurrence-review/design-review2.json.
+Baseline: application branch 2074881; live binary d961b65. This support
+branch does not approve the application's entire unmerged history.
+
+Scope: bounded, exact stepping within the existing recurrence subset.
+Keep every currently parsed positive INTERVAL; no parser or picker limit.
+No UI copy, schema, migrations, COUNT fast-forwarding or new RRULE shapes.
+
+## Evidence
+
+Weekly INTERVAL=100000000 with BYDAY exceeds a two-second child deadline.
+Civil 1600-01-01 to 2000-01-01 saturates at 106751 rather than146097 days.
+A valid INTERVAL=20871 Saturday series silently loses its2000 occurrence
+and reports complete. Yearly MaxInt wraps backwards. Invalid zone/start/end
+reports complete, and a store-level test confirms strict busy-time reads
+return nil rather than ErrIncompleteExpansion for those invalid fields.
+
+## Revised implementation contract
+
+1. Use civil(t).Unix()/86400 for integer civil days within the representable
+ record domain: year0000 through9999, the four-digit local date format.
+ All parsed source dates fit this domain. Generated candidates are bounded
+ before arithmetic; no arbitrary-year Unix round-trip machinery is needed.
+ daysBetween subtracts civil-day integers, never time.Duration or floats.
+2. Weekly BYDAY considers remaining selected weekdays in the current eligible
+ week, then the first selected weekday in the next INTERVAL-th week, ordered
+ from WKST. cur is eligible by induction from DTSTART. At most13 weekday
+ candidates; no scanning proportional to INTERVAL and no saturated phase.
+ Construct each candidate with the original proto clock as before.
+3. Check distance to the final representable civil date by division before
+ multiplying. Daily compares interval with remaining civil days; weekly
+ compares interval with remaining days/7 before computing its week jump.
+ Monthly compares interval with remaining month indexes/step; yearly with
+ remaining years/step. Thus every addition/product stays inside the small
+ four-digit date domain. Do not change parsing or stored INTERVAL.
+4. Test calendar-date existence before zone resolution. Yearly checks month
+ length before constructing the candidate; monthly ordinal weekday arithmetic
+ uses UTC civil weekdays, then resolves the selected date in loc. A complete
+ flat4800-month-step or400-year-step search covers every Gregorian residue
+ reachable by any integer interval, because those periods repeat the calendar.
+ No match after that full search proves exhaustion. A horizon reached first
+ does not prove exhaustion. No gcd machinery is required.
+5. Stepping distinguishes candidate, proved exhaustion and incomplete. A
+ horizon refusal is ALWAYS INCOMPLETE, regardless of query end: this avoids
+ claiming a UTC cutoff for zones whose next local year starts before UTC's.
+ Invalid Rule construction, non-advancing resolved candidates and resolved
+ dates outside year0000..9999 also report incomplete. Ordinary DST clock
+ normalization remains unchanged; intended-versus-resolved dates across a
+ midnight gap remain a separate pre-existing issue, not silently redesigned.
+6. Invalid zone/start/end or reversed event/window intervals make strict
+ expansion incomplete. For an unreadable end preserve tolerant instantaneous
+ output where possible, but retain an input-validity flag so no stop reason
+ can turn it into complete. Existing canceled/note exceptions remain.
+7. COUNT/UNTIL satisfaction, passing the window, a valid one-off or proved
+ exhaustion are completion proofs. Check COUNT after generating its final
+ candidate so no unneeded next step can turn completed COUNT into a horizon
+ failure. Keep existing100000 outer/1000 output caps and EXDATE ordering.
+
+## Validation
+
+Adapt the two-second huge-interval probe to expect prompt incomplete refusal;
+add INTERVAL=400000 within year9999 with exact date/weekday/clock. Keep the
+four-century observable regression, reversed civil span, huge-interval cases
+for every frequency, exact near-horizon boundaries, monthly31st/fifth weekday
+and leap-day skips, WKST/DTSTART phase and existing DST/span tests.
+
+Strict store regression must fail before the fix and pass after it while the
+view remains tolerant. Test invalid input, COUNT completion near the horizon,
+query beyond the horizon and unsupported direct Rule values. Mutation-prove
+bounded stepping, exact day arithmetic and incomplete propagation. Run make ci,
+then independent Fable branch review and actual CI before owner integration.
+No production rollout of the unmerged application history is authorized here.
+
+Revision1 was REQUEST CHANGES by Claude Fable5.1 (claude-fable-5-1), evidence
+/tmp/oficina-calendar-recurrence-review/design-review.json. This revision
+accepts the four-digit horizon, exact civil-day arithmetic, pre-zone cycle
+proof and bounded weekly induction; explicitly chooses safe incomplete horizon
+handling from the review's allowed alternatives.
diff --git a/internal/calendar/app.go b/internal/calendar/app.go
index a04ef05..86e14bf 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -124,6 +124,11 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("calendar: writer handle: %w", err)
}
+ if err := checkDataKey(d.G, sealer); err != nil {
+ d.Close()
+ return nil, err
+ }
+
// ONE bounded sender for everything this app sends — sign-in,
// reminders, booking mail. The recorder stays inside it, so tests
// still read what was sent (booking spec §6).
diff --git a/internal/calendar/key_check.go b/internal/calendar/key_check.go
new file mode 100644
index 0000000..c75e250
--- /dev/null
+++ b/internal/calendar/key_check.go
@@ -0,0 +1,49 @@
+package calendar
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+ "gorm.io/gorm"
+)
+
+const keyCheckMeta = "sealed_key_check_v1"
+const keyCheckPurpose = "key.check.v1"
+const keyCheckValue = "oficina/calendar key check v1"
+
+func checkDataKey(g *gorm.DB, sealer *seal.Sealer) error {
+ return g.Transaction(func(tx *gorm.DB) error {
+ var row Meta
+ err := tx.Where("key = ?", keyCheckMeta).First(&row).Error
+ if err == nil {
+ sealed, err := base64.StdEncoding.DecodeString(row.Value)
+ if err != nil {
+ return errors.New("calendar: invalid data key check")
+ }
+ plain, err := sealer.OpenString(keyCheckPurpose, sealed)
+ if err != nil || plain != keyCheckValue {
+ return errors.New("calendar: data key does not match database")
+ }
+ return nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ var calendars []Calendar
+ if err := tx.Find(&calendars).Error; err != nil {
+ return err
+ }
+ for _, c := range calendars {
+ if _, err := sealer.OpenString(seal.PurposeCalendarName, c.NameSealed); err != nil {
+ return fmt.Errorf("calendar: data key cannot open existing calendar: %w", err)
+ }
+ }
+ sealed, err := sealer.SealString(keyCheckPurpose, keyCheckValue)
+ if err != nil {
+ return err
+ }
+ return tx.Create(&Meta{Key: keyCheckMeta, Value: base64.StdEncoding.EncodeToString(sealed)}).Error
+ })
+}
diff --git a/internal/calendar/occurrences.go b/internal/calendar/occurrences.go
index eb0db8e..2ab500b 100644
--- a/internal/calendar/occurrences.go
+++ b/internal/calendar/occurrences.go
@@ -56,15 +56,19 @@ func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string
// 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) {
+ if from >= to || !validExpansionRule(r) {
+ return nil, false
+ }
loc, err := Zone(tz)
if err != nil {
- return nil, true
+ return nil, false
}
start, _, err := ParseLocal(startLocal, loc)
if err != nil {
- return nil, true
+ return nil, false
}
end, _, err := ParseLocal(endLocal, loc)
+ validInput := err == nil && inDateHorizon(start) && inDateHorizon(end) && !end.Before(start)
if err != nil {
// An event with an unreadable end still has a start; treat it
// as instantaneous rather than dropping it entirely.
@@ -83,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
@@ -93,7 +98,10 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
break
}
occStart := cur
- occEnd := shiftDays(cur, dayspan, end)
+ occEnd, endOK := shiftDays(cur, dayspan, end)
+ if !endOK {
+ break
+ }
su := occStart.Unix()
eu := occEnd.Unix()
@@ -102,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
@@ -121,20 +135,24 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
break
}
- next, ok := advance(cur, start, r, loc)
- if !ok {
+ if r.Count > 0 && generated >= r.Count {
complete = true
break
}
+ next, state := advance(cur, start, r, loc)
+ if state != stepCandidate {
+ complete = state == stepExhausted
+ break
+ }
cur = next
}
- return out, complete
+ return out, complete && validInput && !uncertainWindow
}
// shiftDays puts proto's time of day onto a date dayspan days after cur.
-func shiftDays(cur time.Time, dayspan int, proto time.Time) time.Time {
- d := cur.AddDate(0, 0, dayspan)
- return time.Date(d.Year(), d.Month(), d.Day(), proto.Hour(), proto.Minute(), 0, 0, cur.Location())
+func shiftDays(cur time.Time, dayspan int, proto time.Time) (time.Time, bool) {
+ next, state := candidateOnDay(civilDay(cur)+int64(dayspan), proto, cur.Location())
+ return next, state == stepCandidate
}
func untilLocal(r Rule, loc *time.Location) time.Time {
diff --git a/internal/calendar/occurrences_bounds.go b/internal/calendar/occurrences_bounds.go
new file mode 100644
index 0000000..f644e52
--- /dev/null
+++ b/internal/calendar/occurrences_bounds.go
@@ -0,0 +1,57 @@
+package calendar
+
+import "time"
+
+var firstCivilDay = civilDay(time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC))
+var lastCivilDay = civilDay(time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC))
+
+func civilDay(t time.Time) int64 { return civil(t).Unix() / 86400 }
+func inDateHorizon(t time.Time) bool { return t.Year() >= 0 && t.Year() <= 9999 }
+
+func candidateOnDay(day int64, proto time.Time, loc *time.Location) (time.Time, stepState) {
+ if day < firstCivilDay || day > lastCivilDay {
+ return time.Time{}, stepIncomplete
+ }
+ candidate := onDate(time.Unix(day*86400, 0).UTC(), proto, loc)
+ if !inDateHorizon(candidate) {
+ return time.Time{}, stepIncomplete
+ }
+ return candidate, stepCandidate
+}
+
+func validExpansionRule(r Rule) bool {
+ if r.Count < 0 || r.Count > 0 && r.HasUntil {
+ return false
+ }
+ if r.HasUntil && !inDateHorizon(r.Until) {
+ return false
+ }
+ if r.Freq == "" {
+ return len(r.ByDay) == 0 && r.Ordinal == 0 && r.MonthDay == 0
+ }
+ if r.Interval < 1 {
+ return false
+ }
+ switch r.Freq {
+ case FreqWeekly:
+ if r.WeekStart < time.Sunday || r.WeekStart > time.Saturday || r.MonthDay != 0 || r.Ordinal != 0 {
+ return false
+ }
+ for _, day := range r.ByDay {
+ if day < time.Sunday || day > time.Saturday {
+ return false
+ }
+ }
+ case FreqMonthly:
+ if len(r.ByDay) != 0 || r.MonthDay < 0 || r.MonthDay > 31 || r.Ordinal != 0 && (r.MonthDay != 0 || r.OrdinalDay < time.Sunday || r.OrdinalDay > time.Saturday) {
+ return false
+ }
+ case FreqDaily, FreqYearly:
+ if len(r.ByDay) != 0 || r.MonthDay != 0 || r.Ordinal != 0 {
+ return false
+ }
+ default:
+ return false
+ }
+ return true
+}
diff --git a/internal/calendar/occurrences_step.go b/internal/calendar/occurrences_step.go
index b010ef2..23694c3 100644
--- a/internal/calendar/occurrences_step.go
+++ b/internal/calendar/occurrences_step.go
@@ -15,62 +15,85 @@ import "time"
// 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) {
+type stepState uint8
+
+const (
+ stepIncomplete stepState = iota
+ stepCandidate
+ stepExhausted
+)
+
+func advance(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) {
+ if !validExpansionRule(r) || !inDateHorizon(cur) || !inDateHorizon(proto) {
+ return time.Time{}, stepIncomplete
+ }
+ if r.Freq == "" {
+ return time.Time{}, stepExhausted
+ }
+ next, state := nextCandidate(cur, proto, r, loc)
+ if state == stepCandidate && (!inDateHorizon(next) || !next.After(cur)) {
+ return time.Time{}, stepIncomplete
+ }
+ return next, state
+}
+
+func nextCandidate(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) {
switch r.Freq {
case FreqDaily:
- return onDate(cur.AddDate(0, 0, r.Interval), proto, loc), true
-
+ if int64(r.Interval) > lastCivilDay-civilDay(cur) {
+ return time.Time{}, stepIncomplete
+ }
+ return candidateOnDay(civilDay(cur)+int64(r.Interval), proto, loc)
case FreqWeekly:
+ day := civilDay(cur)
if len(r.ByDay) == 0 {
- return onDate(cur.AddDate(0, 0, 7*r.Interval), proto, loc), true
+ if int64(r.Interval) > (lastCivilDay-day)/7 {
+ return time.Time{}, stepIncomplete
+ }
+ return candidateOnDay(day+7*int64(r.Interval), proto, loc)
}
- // 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
+ k := daysSinceWeekStart(cur.Weekday(), r.WeekStart)
+ for off := 1; off <= 6-k; off++ {
+ if containsWeekday(r.ByDay, time.Weekday((int(cur.Weekday())+off)%7)) {
+ return candidateOnDay(day+int64(off), proto, loc)
}
- if r.Interval > 1 && weeksBetween(proto, cand, r.WeekStart)%r.Interval != 0 {
- continue
+ }
+ weekStart := day - int64(k)
+ if int64(r.Interval) > (lastCivilDay-weekStart)/7 {
+ return time.Time{}, stepIncomplete
+ }
+ weekStart += 7 * int64(r.Interval)
+ for off := 0; off < 7; off++ {
+ if containsWeekday(r.ByDay, time.Weekday((int(r.WeekStart)+off)%7)) {
+ return candidateOnDay(weekStart+int64(off), proto, loc)
}
- 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
+ currentMonth := cur.Year()*12 + int(cur.Month()) - 1
+ for step := 1; step <= 4800; step++ {
+ if r.Interval > (9999*12+11-currentMonth)/step {
+ return time.Time{}, stepIncomplete
+ }
+ target := currentMonth + step*r.Interval
+ base := time.Date(target/12, time.Month(target%12+1), 1, 0, 0, 0, 0, time.UTC)
+ if d, ok := monthlyDate(base, r, proto, loc); ok {
+ return d, stepCandidate
}
}
- return time.Time{}, false
-
+ return time.Time{}, stepExhausted
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
+ for step := 1; step <= 400; step++ {
+ if r.Interval > (9999-cur.Year())/step {
+ return time.Time{}, stepIncomplete
+ }
+ y := cur.Year() + step*r.Interval
+ if proto.Day() <= daysInMonth(y, proto.Month()) {
+ return time.Date(y, proto.Month(), proto.Day(), proto.Hour(), proto.Minute(), 0, 0, loc), stepCandidate
}
}
- return time.Time{}, false
+ return time.Time{}, stepExhausted
}
- return time.Time{}, false
+ return time.Time{}, stepIncomplete
}
// onDate puts proto's time of day onto d's civil date in loc.
@@ -104,8 +127,11 @@ func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (t
// 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 || n > 5 || n < -5 {
+ return time.Time{}, false
+ }
if n > 0 {
- first := time.Date(y, m, 1, hh, mm, 0, 0, loc)
+ first := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC)
delta := (int(wd) - int(first.Weekday()) + 7) % 7
day := 1 + delta + (n-1)*7
if day > daysInMonth(y, m) {
@@ -113,7 +139,7 @@ func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *ti
}
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)
+ last := time.Date(y, m, daysInMonth(y, m), 0, 0, 0, 0, time.UTC)
delta := (int(last.Weekday()) - int(wd) + 7) % 7
day := last.Day() - delta + (n+1)*7
if day < 1 {
@@ -127,10 +153,6 @@ func daysInMonth(y int, m time.Month) int {
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 {
@@ -148,16 +170,6 @@ 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 between two days, for INTERVAL on
-// a weekly rule. Weeks start on wkst — the rule's WKST, Monday by the
-// RFC's default — and where a week starts decides which week a Sunday
-// or a Monday is in.
-func weeksBetween(a, b time.Time, wkst time.Weekday) int {
- da := civil(a).AddDate(0, 0, -daysSinceWeekStart(a.Weekday(), wkst))
- db := civil(b).AddDate(0, 0, -daysSinceWeekStart(b.Weekday(), wkst))
- return int(db.Sub(da).Hours() / (24 * 7))
-}
-
func daysSinceWeekStart(d, wkst time.Weekday) int {
return (int(d) - int(wkst) + 7) % 7
}
@@ -168,5 +180,5 @@ func daysSinceWeekStart(d, wkst time.Weekday) int {
// 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)
+ return int(civilDay(end) - civilDay(start))
}
diff --git a/internal/calendar/recurrence_bounds_probe_test.go b/internal/calendar/recurrence_bounds_probe_test.go
new file mode 100644
index 0000000..600bd72
--- /dev/null
+++ b/internal/calendar/recurrence_bounds_probe_test.go
@@ -0,0 +1,84 @@
+package calendar
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+)
+
+func TestWeeklyLargeIntervalFinishesPromptly(t *testing.T) {
+ if os.Getenv("CALENDAR_WEEKLY_BOUND_PROBE") == "1" {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ r, err := ParseRule("FREQ=WEEKLY;INTERVAL=100000000;BYDAY=MO")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, state := advance(proto, proto, r, time.UTC)
+ if state != stepIncomplete {
+ t.Fatalf("next=%v state=%v want incomplete horizon", got, state)
+ }
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWeeklyLargeIntervalFinishesPromptly$", "-test.count=1")
+ cmd.Env = append(os.Environ(), "CALENDAR_WEEKLY_BOUND_PROBE=1")
+ out, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ t.Fatal("weekly BYDAY expansion exceeded the bounded subprocess deadline")
+ }
+ if err != nil {
+ t.Fatalf("probe: %v\n%s", err, out)
+ }
+}
+
+func TestCivilDateDifferencesDoNotSaturateAt292Years(t *testing.T) {
+ start := time.Date(1600, 1, 1, 9, 0, 0, 0, time.UTC)
+ end := time.Date(2000, 1, 1, 9, 0, 0, 0, time.UTC)
+ if got := daysBetween(start, end); got != 146097 {
+ t.Errorf("civil days=%d want146097", got)
+ }
+ if got := daysBetween(end, start); got != -146097 {
+ t.Errorf("reverse civil days=%d want-146097", got)
+ }
+}
+
+func TestYearlyAdvanceNeverWrapsBackwards(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ for _, interval := range []int{int(^uint(0) >> 1), int(^uint(0)>>1) / 2, int(^uint(0)>>1) / 3} {
+ next, state := advance(proto, proto, Rule{Freq: FreqYearly, Interval: interval}, time.UTC)
+ if state == stepCandidate && !next.After(proto) {
+ t.Errorf("interval=%d next=%v is not later than %v", interval, next, proto)
+ }
+ }
+}
+
+func TestStrictExpansionDoesNotCallInvalidInputComplete(t *testing.T) {
+ r, _ := ParseRule("FREQ=DAILY")
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).Unix()
+ to := from + 86400
+ for _, c := range []struct{ start, end, zone string }{
+ {"2026-09-07T09:00", "2026-09-07T10:00", "Not/AZone"},
+ {"invalid", "2026-09-07T10:00", "UTC"},
+ {"2026-09-07T09:00", "invalid", "UTC"},
+ } {
+ if _, complete := ExpandComplete(c.start, c.end, c.zone, false, r, "", from, to); complete {
+ t.Errorf("invalid input marked complete: %+v", c)
+ }
+ }
+}
+
+func TestFourCenturyWeeklyOccurrenceIsNotSilentlyLost(t *testing.T) {
+ r, err := ParseRule("FREQ=WEEKLY;INTERVAL=20871;BYDAY=SA;COUNT=2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ from := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC).Unix()
+ got, complete := ExpandComplete("1600-01-01T09:00", "1600-01-01T10:00", "UTC", false, r, "", from, to)
+ if !complete || len(got) != 1 || got[0].Local != "2000-01-01T09:00" {
+ t.Fatalf("occurrences=%+v complete=%v; want the second occurrence on 2000-01-01", got, complete)
+ }
+}
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/calendar/recurrence_horizon_test.go b/internal/calendar/recurrence_horizon_test.go
new file mode 100644
index 0000000..9ff0f73
--- /dev/null
+++ b/internal/calendar/recurrence_horizon_test.go
@@ -0,0 +1,107 @@
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestLargeRepresentableWeeklyIntervalIsExact(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=WEEKLY;INTERVAL=400000;BYDAY=MO")
+ next, state := advance(proto, proto, r, time.UTC)
+ want := time.Date(9692, 10, 27, 9, 0, 0, 0, time.UTC)
+ if state != stepCandidate || !next.Equal(want) || next.Weekday() != time.Monday {
+ t.Fatalf("next=%v state=%v want=%v", next, state, want)
+ }
+}
+
+func TestHugeIntervalsRefuseWithoutOverflow(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ for _, freq := range []string{FreqDaily, FreqWeekly, FreqMonthly, FreqYearly} {
+ for _, interval := range []int{int(^uint(0) >> 1), int(^uint(0)>>1) / 2} {
+ next, state := advance(proto, proto, Rule{Freq: freq, Interval: interval}, time.UTC)
+ if state != stepIncomplete {
+ t.Errorf("%s interval%d next%v state%v", freq, interval, next, state)
+ }
+ }
+ }
+}
+
+func TestHorizonAllowsItsLastDayAndRefusesTheNext(t *testing.T) {
+ proto := time.Date(9999, 12, 30, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=DAILY")
+ next, state := advance(proto, proto, r, time.UTC)
+ if state != stepCandidate || next.Format(LayoutLocal) != "9999-12-31T09:00" {
+ t.Fatalf("last day=%v state=%v", next, state)
+ }
+ if _, state = advance(next, proto, r, time.UTC); state != stepIncomplete {
+ t.Fatal("accepted date beyond four-digit record horizon")
+ }
+ weekly, _ := ParseRule("FREQ=WEEKLY;BYDAY=SA")
+ if _, state = advance(next, next, weekly, time.UTC); state != stepIncomplete {
+ t.Fatal("accepted weekly day beyond horizon")
+ }
+}
+
+func TestCountCompletionDoesNotRequireAnotherRepresentableStep(t *testing.T) {
+ from := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(10000, 1, 2, 0, 0, 0, 0, time.UTC).Unix()
+ r, _ := ParseRule("FREQ=YEARLY;INTERVAL=99999999;COUNT=1")
+ got, complete := ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "UTC", false, r, "", from, to)
+ if len(got) != 1 || !complete {
+ t.Fatalf("completed COUNT=%v complete=%v", got, complete)
+ }
+ r.Count = 0
+ if _, complete = ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "Pacific/Kiritimati", false, r, "", from-86400, to); complete {
+ t.Fatal("horizon is not proof of completion, including zones east of UTC")
+ }
+}
+
+func TestCalendarExhaustionDoesNotUseZoneNormalization(t *testing.T) {
+ proto := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=MONTHLY;INTERVAL=12;BYMONTHDAY=31")
+ if next, state := advance(proto, proto, r, time.UTC); state != stepExhausted {
+ t.Fatalf("impossible February31=%v state=%v", next, state)
+ }
+ for _, ordinal := range []int{int(^uint(0) >> 1), -int(^uint(0) >> 1)} {
+ r := Rule{Freq: FreqMonthly, Interval: 1, Ordinal: ordinal, OrdinalDay: time.Monday}
+ if _, state := advance(proto, proto, r, time.UTC); state != stepExhausted {
+ t.Fatalf("impossible ordinal%d state%v", ordinal, state)
+ }
+ }
+ leap := time.Date(2000, 2, 29, 9, 0, 0, 0, time.UTC)
+ r, _ = ParseRule("FREQ=YEARLY;INTERVAL=100")
+ if next, state := advance(leap, leap, r, time.UTC); state != stepCandidate || next.Year() != 2400 || next.Day() != 29 {
+ t.Fatalf("century leap skip=%v state=%v", next, state)
+ }
+}
+
+func TestInvalidInputsCannotBecomeCompleteAtCountOrWindow(t *testing.T) {
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).Unix()
+ for _, rule := range []Rule{{Freq: FreqDaily, Interval: 0}, {Freq: "HOURLY", Interval: 1}, {Freq: FreqWeekly, Interval: 1, ByDay: []time.Weekday{9}}, {Freq: FreqMonthly, Interval: 1, MonthDay: 32}} {
+ if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, rule, "", from, from+86400); complete {
+ t.Errorf("invalid direct rule complete: %+v", rule)
+ }
+ }
+ r, _ := ParseRule("FREQ=DAILY;COUNT=1")
+ for _, end := range []string{"invalid", "2026-09-07T08:00"} {
+ if _, complete := ExpandComplete("2026-09-07T09:00", end, "UTC", false, r, "", from, from+86400); complete {
+ t.Errorf("invalid end complete: %s", end)
+ }
+ }
+ if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, r, "", from+86400, from); complete {
+ t.Fatal("reversed window complete")
+ }
+}
+
+func TestGapOccurrenceDoesNotTruncateLaterWindows(t *testing.T) {
+ r, _ := ParseRule("FREQ=DAILY")
+ from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC).Unix()
+ got, complete := ExpandComplete("2026-03-01T01:30", "2026-03-01T02:00", "Europe/Dublin", false, r, "", from, from+86400)
+ if len(got) != 1 || !complete {
+ t.Fatalf("April window after spring gap: got=%v complete=%v", got, complete)
+ }
+ if got[0].Local != "2026-04-01T01:30" || got[0].EndUTC-got[0].StartUTC != 1800 {
+ t.Fatalf("April occurrence=%+v", got[0])
+ }
+}
diff --git a/internal/calendar/release_repairs_test.go b/internal/calendar/release_repairs_test.go
new file mode 100644
index 0000000..943700b
--- /dev/null
+++ b/internal/calendar/release_repairs_test.go
@@ -0,0 +1,100 @@
+package calendar
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFeedExportsOneMasterAndAllOverrides(t *testing.T) {
+ a := NewForTest(t, t.TempDir())
+ ctx := t.Context()
+ p, err := a.store.EnsurePersonSetup(ctx, "feed@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cal, err := a.store.DefaultCalendarID(ctx, p.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := EventInput{CalendarID: cal, Title: "Weekly", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "UTC", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed}
+ master, err := a.store.CreateEvent(ctx, p.ID, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.RRule = ""
+ in.StartLocal, in.EndLocal = "2026-10-05T11:00", "2026-10-05T12:00"
+ if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, "2026-09-03T09:00", in); err != nil {
+ t.Fatal(err)
+ }
+ from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
+ rows, err := a.store.EventsForFeed(ctx, p.ID, cal, from, from+7*86400)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := WriteICS("Test", rows, false)
+ if len(rows) != 2 || strings.Count(out, "RRULE:") != 1 || strings.Count(out, "BEGIN:VEVENT") != 2 || !strings.Contains(out, "RECURRENCE-ID;TZID=UTC:20260903T0900") || !strings.Contains(out, "DTSTART;TZID=UTC:20261005T1100") {
+ t.Fatalf("lost master or moved override: %s", out)
+ }
+ if _, err := a.store.EventsForFeed(ctx, p.ID+100, cal, from, from+7*86400); err == nil {
+ t.Fatal("foreign feed accepted")
+ }
+ var overrideID int64
+ for _, row := range rows {
+ if row.SeriesID != nil {
+ overrideID = row.ID
+ }
+ }
+ if err := a.store.DeleteEvent(ctx, p.ID, overrideID); err != nil {
+ t.Fatal(err)
+ }
+ rows, err = a.store.EventsForFeed(ctx, p.ID, cal, from, from+7*86400)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, row := range rows {
+ if row.ID == overrideID && row.Sequence <= master.Sequence+1 {
+ t.Fatal("cancellation did not supersede the live override")
+ }
+ }
+ if !strings.Contains(WriteICS("Test", rows, false), "STATUS:CANCELLED") {
+ t.Fatal("deleted override resurrected")
+ }
+}
+
+func TestBootRejectsWrongDataKeyWithAndWithoutSentinel(t *testing.T) {
+ for _, legacy := range []bool{false, true} {
+ t.Run(map[bool]string{false: "current", true: "legacy"}[legacy], func(t *testing.T) {
+ cfg := Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: TestOrigin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}
+ a, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.store.EnsurePersonSetup(t.Context(), "key@example.test"); err != nil {
+ t.Fatal(err)
+ }
+ if legacy {
+ if err := a.db.G.Where("key = ?", keyCheckMeta).Delete(&Meta{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ a.Close()
+ wrong := cfg
+ wrong.DEK = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32))
+ if a, err := New(wrong); err == nil {
+ a.Close()
+ t.Fatal("boot accepted wrong key")
+ }
+ a, err = New(cfg)
+ if err != nil {
+ t.Fatal("correct key failed after refusal", err)
+ }
+ a.Close()
+ })
+ }
+}
diff --git a/internal/calendar/store_event_write.go b/internal/calendar/store_event_write.go
index f6e7607..b924fec 100644
--- a/internal/calendar/store_event_write.go
+++ b/internal/calendar/store_event_write.go
@@ -115,7 +115,7 @@ func (s *Store) updateEventOn(tx *gorm.DB, existing Event, in EventInput, now ti
Where("id = ?", stored.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("calendar: update event: %w", err)
}
- return nil
+ return s.rescheduleAllForTx(tx, stored.ID)
}
// DeleteEvent soft-deletes an event and, when it is a series master,
@@ -135,11 +135,10 @@ func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error
// told about, and its CANCEL ICS must outrank the REQUEST, so
// the soft-deleted row takes one more revision (booking spec
// §5.5).
- updates := map[string]any{"deleted_at": now}
+ updates := map[string]any{"deleted_at": now, "updated_at": now, "sequence": gorm.Expr("sequence + 1")}
if _, booked, err := bookingFor(tx, eventID); err != nil {
return err
} else if booked {
- updates["sequence"] = gorm.Expr("sequence + 1")
if err := cancelBookingsForEvent(tx, eventID, now); err != nil {
return err
}
diff --git a/internal/calendar/store_feed.go b/internal/calendar/store_feed.go
index 2e39b95..0b14b84 100644
--- a/internal/calendar/store_feed.go
+++ b/internal/calendar/store_feed.go
@@ -125,7 +125,7 @@ func (s *Store) RevokeFeed(ctx context.Context, personID, feedID int64) error {
// EventsForFeed reads a calendar's events for export, opening the UID
// and attaching the feed owner's own reminder offsets.
func (s *Store) EventsForFeed(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
- all, err := s.EventsInRange(ctx, personID, from, to)
+ all, err := s.feedEventRows(ctx, personID, calendarID, from, to)
if err != nil {
return nil, err
}
diff --git a/internal/calendar/store_feed_rows.go b/internal/calendar/store_feed_rows.go
new file mode 100644
index 0000000..24fe683
--- /dev/null
+++ b/internal/calendar/store_feed_rows.go
@@ -0,0 +1,72 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "sort"
+)
+
+func (s *Store) feedEventRows(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
+ if _, err := s.Calendar(ctx, personID, calendarID); err != nil {
+ return nil, err
+ }
+ rows, err := s.rangeRows(s.g.WithContext(ctx), personID, from, to)
+ if err != nil {
+ return nil, err
+ }
+ events := map[int64]EventView{}
+ masters := map[int64]bool{}
+ for _, r := range rows {
+ if r.CalendarID != calendarID {
+ continue
+ }
+ id := r.ID
+ if r.SeriesID != nil {
+ id = *r.SeriesID
+ }
+ if _, exists := events[id]; exists {
+ continue
+ }
+ v, err := s.Event(ctx, personID, id)
+ if errors.Is(err, ErrNotFound) {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ if v.CalendarID != calendarID {
+ continue
+ }
+ events[id] = v
+ if v.RRule != "" {
+ masters[id] = true
+ }
+ }
+ ids := make([]int64, 0, len(masters))
+ for id := range masters {
+ ids = append(ids, id)
+ }
+ overrides, err := s.overridesFor(s.g.WithContext(ctx), personID, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range overrides {
+ if r.CalendarID != calendarID {
+ continue
+ }
+ v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ if r.DeletedAt != nil {
+ v.Status = StatusCancelled
+ }
+ events[v.ID] = v
+ }
+ out := make([]EventView, 0, len(events))
+ for _, v := range events {
+ out = append(out, v)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}
diff --git a/internal/calendar/store_override.go b/internal/calendar/store_override.go
index ee78c39..5f84de2 100644
--- a/internal/calendar/store_override.go
+++ b/internal/calendar/store_override.go
@@ -189,9 +189,12 @@ func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, origi
// Overrides after the cut belong to the tail, not the head.
// Left on the head they would be orphans: rows nothing renders
// and nothing can reach.
- return tx.Model(&Event{}).
+ if err := tx.Model(&Event{}).
Where("series_id = ? AND start_utc >= ?", seriesID, cut.Unix()).
- Update("series_id", ev.ID).Error
+ Update("series_id", ev.ID).Error; err != nil {
+ return err
+ }
+ return s.rescheduleAllForTx(tx, seriesID)
})
}
diff --git a/internal/calendartest/booking_review2_test.go b/internal/calendartest/booking_review2_test.go
index dd5d957..44babe8 100644
--- a/internal/calendartest/booking_review2_test.go
+++ b/internal/calendartest/booking_review2_test.go
@@ -77,7 +77,19 @@ func TestARescheduleIntoWithdrawnHoursIsRefused(t *testing.T) {
slots := slotsFor(t, app, page, host, now, 3)
b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now)
st.ConfirmBooking(ctx, b.ID, now)
- late := slots[len(slots)-1] // 16:30 on the third day: outside 9-10
+ loc, err := calendar.Zone(host.Zone)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var late calendar.Slot
+ for _, slot := range slots {
+ if time.Unix(slot.StartUTC, 0).In(loc).Hour() >= 10 {
+ late = slot
+ }
+ }
+ if late.StartUTC == 0 {
+ t.Fatal("no slot outside the withdrawn hours")
+ }
ws, _ := calendar.ParseAvailability("every day 9-10")
app.SetAfterSlotCheckForTest(func() {
diff --git a/internal/calendartest/edit_reminder_reschedule_test.go b/internal/calendartest/edit_reminder_reschedule_test.go
new file mode 100644
index 0000000..b690a67
--- /dev/null
+++ b/internal/calendartest/edit_reminder_reschedule_test.go
@@ -0,0 +1,195 @@
+package calendartest
+
+import (
+ "database/sql"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+type editReminderFixture struct {
+ app *calendar.App
+ session *http.Cookie
+ path string
+ id, cal int64
+ start time.Time
+ form url.Values
+}
+type editReminderJob struct {
+ Due int64
+ Key string
+ Claim, Retry sql.NullString
+ Attempts int
+}
+
+func newEditReminderFixture(t *testing.T, repeat string) editReminderFixture {
+ t.Helper()
+ app, session := signedIn(t, "edit-reminder@example.test")
+ start := time.Now().UTC().AddDate(0, 0, 3).Truncate(time.Minute)
+ form := url.Values{"title": {"Future fixture"}, "start": {start.Format(calendar.LayoutLocal)}, "end": {start.Add(time.Hour).Format(calendar.LayoutLocal)}, "tz": {"UTC"}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {repeat}}
+ path := createEvent(t, app.Handler(), session, form)
+ id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cal int64
+ if err = app.DBForTest().QueryRow("SELECT calendar_id FROM events WHERE id=?", id).Scan(&cal); err != nil {
+ t.Fatal(err)
+ }
+ return editReminderFixture{app, session, path, id, cal, start, form}
+}
+func reminderJob(t *testing.T, app *calendar.App, id int64) (editReminderJob, bool) {
+ t.Helper()
+ var q editReminderJob
+ err := app.DBForTest().QueryRow(`SELECT q.due_at,q.occurrence_key,q.claim_id,q.retry_at,q.attempts FROM reminder_queues q JOIN reminders r ON r.id=q.reminder_id WHERE r.event_id=?`, id).Scan(&q.Due, &q.Key, &q.Claim, &q.Retry, &q.Attempts)
+ if err == sql.ErrNoRows {
+ return q, false
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return q, true
+}
+func markReminderClaim(t *testing.T, f editReminderFixture) {
+ t.Helper()
+ _, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET claim_id='old-worker',claimed_at=?,retry_at=?,attempts=2 WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, time.Now().Unix(), f.start.Unix(), f.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+func unrelatedReminder(t *testing.T, f editReminderFixture) (int64, editReminderJob) {
+ t.Helper()
+ form := url.Values{"title": {"Unrelated fixture"}, "start": {f.start.AddDate(0, 0, 1).Format(calendar.LayoutLocal)}, "end": {f.start.AddDate(0, 0, 1).Add(time.Hour).Format(calendar.LayoutLocal)}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}}
+ path := createEvent(t, f.app.Handler(), f.session, form)
+ id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64)
+ if err != nil {
+ t.Fatal(err)
+ }
+ q, ok := reminderJob(t, f.app, id)
+ if !ok {
+ t.Fatal("unrelated job absent")
+ }
+ return id, q
+}
+func TestOrdinaryEditsReplaceReminderJobs(t *testing.T) {
+ for _, kind := range []string{"earlier", "later", "cancelled"} {
+ t.Run(kind, func(t *testing.T) {
+ f := newEditReminderFixture(t, "none")
+ other, oldOther := unrelatedReminder(t, f)
+ old, ok := reminderJob(t, f.app, f.id)
+ if !ok {
+ t.Fatal("original job absent")
+ }
+ markReminderClaim(t, f)
+ next := f.start.Add(2 * time.Hour)
+ if kind == "earlier" {
+ next = f.start.Add(-2 * time.Hour)
+ }
+ f.form.Set("start", next.Format(calendar.LayoutLocal))
+ f.form.Set("end", next.Add(time.Hour).Format(calendar.LayoutLocal))
+ if kind == "cancelled" {
+ f.form.Set("status", "cancelled")
+ }
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusSeeOther {
+ t.Fatalf("edit status=%d", r.Code)
+ }
+ q, found := reminderJob(t, f.app, f.id)
+ if kind == "cancelled" {
+ if found {
+ t.Fatalf("cancelled event still queued: %+v", q)
+ }
+ } else if !found || q.Due != next.Unix()-600 || q.Due == old.Due || q.Claim.Valid || q.Retry.Valid || q.Attempts != 0 {
+ t.Fatalf("replacement job=%+v found=%v", q, found)
+ }
+ after, found := reminderJob(t, f.app, other)
+ if !found || after != oldOther {
+ t.Fatal("unrelated reminder changed")
+ }
+ res, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET fired_at=1 WHERE claim_id='old-worker' AND reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, f.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n, _ := res.RowsAffected()
+ if n != 0 {
+ t.Fatal("old worker retained completion authority")
+ }
+ })
+ }
+}
+func TestSplitSeriesRemovesFinishedHeadReminder(t *testing.T) {
+ f := newEditReminderFixture(t, "daily")
+ other, oldOther := unrelatedReminder(t, f)
+ markReminderClaim(t, f)
+ f.form.Set("scope", "following")
+ f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal))
+ f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal))
+ f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal))
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusSeeOther {
+ t.Fatalf("split status=%d", r.Code)
+ }
+ if q, found := reminderJob(t, f.app, f.id); found {
+ t.Fatalf("finished head queued: %+v", q)
+ }
+ q, found := reminderJob(t, f.app, other)
+ if !found || q != oldOther {
+ t.Fatal("unrelated reminder changed")
+ }
+ var count int
+ if err := f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=? AND rrule<>''", f.cal).Scan(&count); err != nil || count != 2 {
+ t.Fatalf("head/tail count=%d err=%v", count, err)
+ }
+}
+func TestReminderFailureRollsBackEditAndSplit(t *testing.T) {
+ for _, split := range []bool{false, true} {
+ t.Run(strconv.FormatBool(split), func(t *testing.T) {
+ repeat := "none"
+ operation := "UPDATE"
+ if split {
+ repeat = "daily"
+ operation = "DELETE"
+ }
+ f := newEditReminderFixture(t, repeat)
+ old, ok := reminderJob(t, f.app, f.id)
+ if !ok {
+ t.Fatal("job missing")
+ }
+ _, err := f.app.DBForTest().Exec("CREATE TRIGGER reject_reminder_change BEFORE " + operation + " ON reminder_queues BEGIN SELECT RAISE(ABORT,'fixture refusal'); END")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal))
+ f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal))
+ if split {
+ f.form.Set("scope", "following")
+ f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal))
+ }
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusInternalServerError {
+ t.Fatalf("refusal status=%d", r.Code)
+ }
+ q, found := reminderJob(t, f.app, f.id)
+ if !found || q != old {
+ t.Fatal("job changed despite rollback")
+ }
+ var start int64
+ var rule string
+ var count int
+ if err = f.app.DBForTest().QueryRow("SELECT start_utc,rrule FROM events WHERE id=?", f.id).Scan(&start, &rule); err != nil {
+ t.Fatal(err)
+ }
+ if start != f.start.Unix() || (split && strings.Contains(rule, "UNTIL")) {
+ t.Fatalf("event changed despite rollback: %d %s", start, rule)
+ }
+ if err = f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=?", f.cal).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("partial tail count=%d err=%v", count, err)
+ }
+ })
+ }
+}
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))
+ }
+ }
+}
diff --git a/internal/calendartest/recurrence_invalid_test.go b/internal/calendartest/recurrence_invalid_test.go
new file mode 100644
index 0000000..847300e
--- /dev/null
+++ b/internal/calendartest/recurrence_invalid_test.go
@@ -0,0 +1,30 @@
+package calendartest
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+func TestStrictRangeRejectsUnreadableBusySeries(t *testing.T) {
+ for _, field := range []string{"tz", "start_local", "end_local"} {
+ t.Run(field, func(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ createEvent(t, app.Handler(), session, url.Values{"title": {"fixture"}, "start": {testAt(1, "09:00")}, "end": {testAt(1, "10:00")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}})
+ if _, err := app.DBForTest().Exec("UPDATE events SET "+field+" = ? WHERE rrule <> ''", "invalid"); err != nil {
+ t.Fatal(err)
+ }
+ person := app.PersonByAddressForTest(t, "someone@example.com")
+ from, to := window(t, 1, 3)
+ if _, err := app.StoreForTest().EventsInRange(context.Background(), person.ID, from, to); err != nil {
+ t.Fatalf("tolerant view: %v", err)
+ }
+ if _, err := app.StoreForTest().EventsInRangeStrict(context.Background(), person.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) {
+ t.Fatalf("strict result=%v want incomplete expansion", err)
+ }
+ })
+ }
+}