Merge branch 'fix/release-repairs' into main
19 files changed,
+1009
−75
AGENTS.md+2 −0docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md+81 −0internal/calendar/app.go+5 −0internal/calendar/key_check.go+49 −0internal/calendar/occurrences.go+28 −10internal/calendar/occurrences_bounds.go+57 −0internal/calendar/occurrences_step.go+70 −58internal/calendar/recurrence_bounds_probe_test.go+84 −0internal/calendar/recurrence_gap_stopgap_test.go+52 −0internal/calendar/recurrence_horizon_test.go+107 −0internal/calendar/release_repairs_test.go+100 −0internal/calendar/store_event_write.go+2 −3internal/calendar/store_feed.go+1 −1internal/calendar/store_feed_rows.go+72 −0internal/calendar/store_override.go+5 −2internal/calendartest/booking_review2_test.go+13 −1internal/calendartest/edit_reminder_reschedule_test.go+195 −0internal/calendartest/recurrence_gap_stopgap_test.go+56 −0internal/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) |
| + } |
| + }) |
| + } |
| +} |