summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/models/recurrence.go57
-rw-r--r--internal/models/recurrence_test.go51
2 files changed, 108 insertions, 0 deletions
diff --git a/internal/models/recurrence.go b/internal/models/recurrence.go
new file mode 100644
index 0000000..f42183c
--- /dev/null
+++ b/internal/models/recurrence.go
@@ -0,0 +1,57 @@
+package models
+
+import (
+ "sort"
+ "time"
+)
+
+// ComputeNextOccurrence returns the next occurrence date after due, given a
+// recurrence pattern. weekdays is only consulted when freq == "weekly"; nil
+// or empty means "same weekday as due, every interval weeks." Unknown freq
+// values return due unchanged. interval < 1 is treated as 1.
+//
+// Monthly/yearly rollover uses Go's standard AddDate overflow behavior (a
+// due date of Jan 31 + 1 month becomes Mar 3, not clamped to Feb's last
+// day) -- this is an accepted simplification, and the drift is permanent:
+// each call computes from the previous call's actual result, not an
+// original anchor day, so a drifted date locks onto its new day-of-month
+// going forward. Only anchor days 29-31 are ever affected; every month has
+// at least 28 days, so any anchor day <= 28 never drifts.
+func ComputeNextOccurrence(due time.Time, freq string, interval int, weekdays []int) time.Time {
+ if interval < 1 {
+ interval = 1
+ }
+ switch freq {
+ case "daily":
+ return due.AddDate(0, 0, interval)
+ case "weekly":
+ return nextWeeklyOccurrence(due, interval, weekdays)
+ case "monthly":
+ return due.AddDate(0, interval, 0)
+ case "yearly":
+ return due.AddDate(interval, 0, 0)
+ default:
+ return due
+ }
+}
+
+func nextWeeklyOccurrence(due time.Time, interval int, weekdays []int) time.Time {
+ if len(weekdays) == 0 {
+ return due.AddDate(0, 0, 7*interval)
+ }
+ sorted := append([]int(nil), weekdays...)
+ sort.Ints(sorted)
+ dueWeekday := int(due.Weekday())
+
+ for _, wd := range sorted {
+ if wd > dueWeekday {
+ return due.AddDate(0, 0, wd-dueWeekday)
+ }
+ }
+ // Wrapped past the last active weekday this week: land on the first
+ // active weekday, (interval-1) whole weeks further out than the
+ // immediate next week (interval=1 means "next week", interval=2 means
+ // "skip a week", etc).
+ daysToNextWeekStart := 7 - dueWeekday
+ return due.AddDate(0, 0, daysToNextWeekStart+sorted[0]+7*(interval-1))
+}
diff --git a/internal/models/recurrence_test.go b/internal/models/recurrence_test.go
new file mode 100644
index 0000000..8d18996
--- /dev/null
+++ b/internal/models/recurrence_test.go
@@ -0,0 +1,51 @@
+package models
+
+import (
+ "testing"
+ "time"
+)
+
+func TestComputeNextOccurrence(t *testing.T) {
+ mustParse := func(t *testing.T, s string) time.Time {
+ t.Helper()
+ d, err := time.Parse("2006-01-02", s)
+ if err != nil {
+ t.Fatalf("bad fixture date %q: %v", s, err)
+ }
+ return d
+ }
+
+ tests := []struct {
+ name string
+ due string
+ freq string
+ interval int
+ weekdays []int
+ want string
+ }{
+ {"daily interval 1", "2026-07-13", "daily", 1, nil, "2026-07-14"},
+ {"daily interval 3", "2026-07-13", "daily", 3, nil, "2026-07-16"},
+ {"weekly no weekdays interval 1", "2026-07-13", "weekly", 1, nil, "2026-07-20"},
+ {"weekly no weekdays interval 2", "2026-07-13", "weekly", 2, nil, "2026-07-27"},
+ // 2026-07-13 is a Monday (weekday=1).
+ {"weekly with weekdays same week", "2026-07-13", "weekly", 1, []int{1, 3, 5}, "2026-07-15"},
+ {"weekly with weekdays wraps to next week", "2026-07-17", "weekly", 1, []int{1, 3, 5}, "2026-07-20"}, // due=Fri(5), wraps to Mon
+ {"weekly with weekdays interval 2 wraps", "2026-07-13", "weekly", 2, []int{1}, "2026-07-27"}, // due=Mon, only Mon active, skip a week
+ {"monthly interval 1", "2026-06-13", "monthly", 1, nil, "2026-07-13"},
+ {"monthly rollover", "2026-01-31", "monthly", 1, nil, "2026-03-03"},
+ {"monthly rollover locks in on the drifted day", "2026-03-03", "monthly", 1, nil, "2026-04-03"},
+ {"yearly interval 1", "2026-07-13", "yearly", 1, nil, "2027-07-13"},
+ {"unknown freq returns due unchanged", "2026-07-13", "bogus", 1, nil, "2026-07-13"},
+ {"interval below 1 is treated as 1", "2026-07-13", "daily", 0, nil, "2026-07-14"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ComputeNextOccurrence(mustParse(t, tc.due), tc.freq, tc.interval, tc.weekdays)
+ want := mustParse(t, tc.want)
+ if !got.Equal(want) {
+ t.Errorf("ComputeNextOccurrence(%s, %s, %d, %v) = %s, want %s", tc.due, tc.freq, tc.interval, tc.weekdays, got.Format("2006-01-02"), tc.want)
+ }
+ })
+ }
+}