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)) } // CollapseRecurringSeries reduces each recurrence series to its single // earliest-due instance, preserving input order otherwise. // // The scheduler deliberately materializes a series' next instance once the // current one comes due, whether or not it was completed, so that ignoring one // occurrence doesn't stall the series (see // GetSeriesNeedingNextIteration). The side effect is that an unfinished // recurring chore and its already-generated successor both sit in the flat task // list at once -- "Bills" appearing twice, once overdue from Sep 1 and once for // Oct 1. Only the earliest open one is a thing you actually owe right now. // // This is for flat "what's open" views only. Date-anchored views (timeline, // widget) should keep every instance, since there each one legitimately belongs // to its own day. func CollapseRecurringSeries(tasks []Task) []Task { earliest := make(map[string]*Task) for i := range tasks { t := &tasks[i] if t.RecurrenceSeriesID == "" || t.DueDate == nil { continue } cur, ok := earliest[t.RecurrenceSeriesID] if !ok || t.DueDate.Before(*cur.DueDate) { earliest[t.RecurrenceSeriesID] = t } } out := make([]Task, 0, len(tasks)) for i := range tasks { t := tasks[i] // An undated recurring row has no position in the series ordering, so it // is never collapsed away -- only dated instances compete. if keep, ok := earliest[t.RecurrenceSeriesID]; ok && t.DueDate != nil && keep.ID != t.ID { continue } out = append(out, t) } return out }