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)) }