summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-14 20:17:10 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:44:02 +0000
commitc99d52ccf5c52e9c75b6fcb661c8e74816b34924 (patch)
treec14a8f89cc7c7d76147d12a998479432cde43782
parent0baf1134ea2d415aa55079ff98476afd0acf5811 (diff)
feat(tasks): add ComputeNextOccurrence for recurring task scheduling
Implement pure function to compute next occurrence of recurring tasks given frequency, interval, and optional weekday constraints. Supports daily, weekly, monthly, and yearly recurrence patterns. Weekly recurrence with weekday constraints wraps to next applicable week when needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
-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)
+ }
+ })
+ }
+}