Add smart notification reminder system with frequency-aware scheduling
Replaces one-size-fits-all "2 days before" reminders with intelligent
scheduling based on task frequency. Infrequent tasks (annual) get 30-day
advance notice while frequent tasks (weekly) only get day-of reminders.
Key features:
- Frequency-aware pre-reminders: annual (30d, 14d, 7d), quarterly (7d, 3d),
monthly (3d), bi-weekly (1d), daily/weekly/once (day-of only)
- Overdue tapering: daily for 3 days, then every 3 days, stops after 14 days
- Reminder log table prevents duplicate notifications per due date/stage
- Admin endpoint displays notification schedules for all frequencies
- Comprehensive test suite (100 random tasks, 61 days each, 10 test functions)
New files:
- internal/notifications/reminder_config.go - Editable schedule configuration
- internal/notifications/reminder_schedule.go - Schedule lookup logic
- internal/notifications/reminder_schedule_test.go - Dynamic test suite
- internal/models/reminder_log.go - TaskReminderLog model
- internal/repositories/reminder_repo.go - Reminder log repository
- migrations/010_add_task_reminder_log.{up,down}.sql
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
198
internal/notifications/reminder_schedule.go
Normal file
198
internal/notifications/reminder_schedule.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetScheduleForFrequency returns the reminder schedule (days before due date)
|
||||
// based on the task's frequency interval days.
|
||||
// Returns nil or 0 interval as Once schedule.
|
||||
func GetScheduleForFrequency(intervalDays *int) []int {
|
||||
if intervalDays == nil || *intervalDays == 0 {
|
||||
return FrequencySchedules[0] // Once schedule
|
||||
}
|
||||
|
||||
days := *intervalDays
|
||||
|
||||
// Check for exact match first
|
||||
if schedule, exists := FrequencySchedules[days]; exists {
|
||||
return schedule
|
||||
}
|
||||
|
||||
// For Custom frequencies, find the nearest tier
|
||||
return GetScheduleForCustomInterval(days)
|
||||
}
|
||||
|
||||
// GetScheduleForCustomInterval looks up schedule for Custom frequency
|
||||
// based on CustomIntervalDays value - finds nearest tier
|
||||
func GetScheduleForCustomInterval(days int) []int {
|
||||
// Ordered tiers from smallest to largest
|
||||
tiers := []int{1, 7, 14, 30, 90, 180, 365}
|
||||
|
||||
for _, tier := range tiers {
|
||||
if days <= tier {
|
||||
return FrequencySchedules[tier]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to annual schedule for intervals > 365 days
|
||||
return FrequencySchedules[365]
|
||||
}
|
||||
|
||||
// ShouldSendOverdueReminder checks if we should send an overdue reminder
|
||||
// for a task that is daysOverdue days past its due date.
|
||||
//
|
||||
// Returns true if:
|
||||
// - Days 1 to DailyReminderDays: every day
|
||||
// - Days DailyReminderDays+1 to MaxOverdueDays: every TaperIntervalDays
|
||||
// - After MaxOverdueDays: never
|
||||
func ShouldSendOverdueReminder(daysOverdue int) bool {
|
||||
if daysOverdue <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Phase 1: Daily reminders for first N days
|
||||
if daysOverdue <= OverdueConfig.DailyReminderDays {
|
||||
return true
|
||||
}
|
||||
|
||||
// Phase 2: Tapered reminders (every N days)
|
||||
if daysOverdue <= OverdueConfig.MaxOverdueDays {
|
||||
// Calculate which day in the taper phase
|
||||
daysSinceDaily := daysOverdue - OverdueConfig.DailyReminderDays
|
||||
// Send on days that are multiples of TaperIntervalDays after daily phase
|
||||
// e.g., with DailyReminderDays=3, TaperIntervalDays=3:
|
||||
// Day 4 (daysSinceDaily=1): 1 % 3 = 1 (no)
|
||||
// Day 5 (daysSinceDaily=2): 2 % 3 = 2 (no)
|
||||
// Day 6 (daysSinceDaily=3): 3 % 3 = 0 (yes) -> but we want day 4, 7, 10, 13
|
||||
// Correcting: we want to send on daysSinceDaily = 1, 4, 7, 10...
|
||||
// So (daysSinceDaily - 1) % TaperIntervalDays == 0
|
||||
return (daysSinceDaily-1)%OverdueConfig.TaperIntervalDays == 0
|
||||
}
|
||||
|
||||
// Phase 3: No more reminders
|
||||
return false
|
||||
}
|
||||
|
||||
// GetOverdueReminderDays returns all the days on which overdue reminders
|
||||
// should be sent, based on the current OverdueConfig.
|
||||
func GetOverdueReminderDays() []int {
|
||||
var days []int
|
||||
|
||||
// Add daily reminder days
|
||||
for i := 1; i <= OverdueConfig.DailyReminderDays; i++ {
|
||||
days = append(days, i)
|
||||
}
|
||||
|
||||
// Add tapered reminder days
|
||||
for i := OverdueConfig.DailyReminderDays + 1; i <= OverdueConfig.MaxOverdueDays; i++ {
|
||||
if ShouldSendOverdueReminder(i) {
|
||||
days = append(days, i)
|
||||
}
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
// GetReminderStageForToday determines which reminder stage applies today
|
||||
// for a task with the given due date and frequency.
|
||||
//
|
||||
// Returns:
|
||||
// - "reminder_Nd" if task is N days away and in schedule
|
||||
// - "day_of" if task is due today
|
||||
// - "overdue_N" if task is N days overdue and should be reminded
|
||||
// - empty string if no reminder should be sent today
|
||||
func GetReminderStageForToday(dueDate time.Time, frequencyDays *int, today time.Time) string {
|
||||
// Normalize to date only (midnight)
|
||||
dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Calculate days difference
|
||||
diff := int(dueDate.Sub(today).Hours() / 24)
|
||||
|
||||
if diff > 0 {
|
||||
// Task is in the future - check pre-due reminders
|
||||
schedule := GetScheduleForFrequency(frequencyDays)
|
||||
if slices.Contains(schedule, diff) {
|
||||
return formatDaysBeforeStage(diff)
|
||||
}
|
||||
return ""
|
||||
} else if diff == 0 {
|
||||
// Task is due today
|
||||
return "day_of"
|
||||
} else {
|
||||
// Task is overdue
|
||||
daysOverdue := -diff
|
||||
if ShouldSendOverdueReminder(daysOverdue) {
|
||||
return formatOverdueStage(daysOverdue)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatDaysBeforeStage returns the stage string for days before due date
|
||||
func formatDaysBeforeStage(days int) string {
|
||||
if days == 0 {
|
||||
return "day_of"
|
||||
}
|
||||
return "reminder_" + itoa(days) + "d"
|
||||
}
|
||||
|
||||
// formatOverdueStage returns the stage string for days overdue
|
||||
func formatOverdueStage(days int) string {
|
||||
return "overdue_" + itoa(days)
|
||||
}
|
||||
|
||||
// itoa is a simple int to string helper
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
if i < 0 {
|
||||
return "-" + itoa(-i)
|
||||
}
|
||||
s := ""
|
||||
for i > 0 {
|
||||
s = string(rune('0'+i%10)) + s
|
||||
i /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetHumanReadableSchedule returns a human-readable description of the
|
||||
// notification schedule for a given frequency interval.
|
||||
func GetHumanReadableSchedule(intervalDays *int) string {
|
||||
days := 0
|
||||
if intervalDays != nil {
|
||||
days = *intervalDays
|
||||
}
|
||||
|
||||
if desc, exists := HumanReadableSchedule[days]; exists {
|
||||
return desc
|
||||
}
|
||||
|
||||
// For custom intervals, find the nearest tier
|
||||
tiers := []int{1, 7, 14, 30, 90, 180, 365}
|
||||
for _, tier := range tiers {
|
||||
if days <= tier {
|
||||
return HumanReadableSchedule[tier] + " (custom)"
|
||||
}
|
||||
}
|
||||
|
||||
return HumanReadableSchedule[365] + " (custom)"
|
||||
}
|
||||
|
||||
// GetFrequencyName returns the human-readable name for a frequency interval
|
||||
func GetFrequencyName(intervalDays *int) string {
|
||||
days := 0
|
||||
if intervalDays != nil {
|
||||
days = *intervalDays
|
||||
}
|
||||
|
||||
if name, exists := FrequencyNames[days]; exists {
|
||||
return name
|
||||
}
|
||||
|
||||
return "Custom"
|
||||
}
|
||||
Reference in New Issue
Block a user