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:
Trey t
2025-12-19 23:03:28 -06:00
parent 7a57a902bb
commit 69206c6930
13 changed files with 1733 additions and 28 deletions

View File

@@ -0,0 +1,63 @@
package notifications
// ============================================================
// REMINDER CONFIGURATION
// Edit these values to adjust notification behavior
// ============================================================
// OverdueConfig controls when and how often overdue reminders are sent
var OverdueConfig = struct {
DailyReminderDays int // Send daily reminders for first N days overdue
TaperIntervalDays int // After daily period, remind every N days
MaxOverdueDays int // Stop reminding after N days overdue
}{
DailyReminderDays: 3, // Daily for days 1-3
TaperIntervalDays: 3, // Then every 3 days (4, 7, 10, 13)
MaxOverdueDays: 14, // Stop after 14 days
}
// FrequencySchedules - EXPLICIT entry for each of the 9 seeded frequencies
// Key: interval days (matches TaskFrequency.days in DB, 0 = Once/null)
// Value: array of days before due date to send reminders (0 = day-of)
//
// To add a reminder: append the number of days before to the slice
// Example: FrequencySchedules[30] = []int{7, 3, 0} adds 7-day warning to Monthly
var FrequencySchedules = map[int][]int{
0: {0}, // 1. Once (null/0): day-of only
1: {0}, // 2. Daily: day-of only
7: {0}, // 3. Weekly: day-of only
14: {1, 0}, // 4. Bi-Weekly: 1 day before, day-of
30: {3, 0}, // 5. Monthly: 3 days before, day-of
90: {7, 3, 0}, // 6. Quarterly: 7d, 3d, day-of
180: {14, 7, 0}, // 7. Semi-Annually: 14d, 7d, day-of
365: {30, 14, 7, 0}, // 8. Annually: 30d, 14d, 7d, day-of
}
// HumanReadableSchedule returns admin-friendly description for each frequency
// Key: interval days (matches FrequencySchedules keys)
// Value: human-readable description of the reminder schedule
var HumanReadableSchedule = map[int]string{
0: "Day-of → Overdue (tapering)",
1: "Day-of → Overdue (tapering)",
7: "Day-of → Overdue (tapering)",
14: "1 day before → Day-of → Overdue",
30: "3 days before → Day-of → Overdue",
90: "7d → 3d → Day-of → Overdue",
180: "14d → 7d → Day-of → Overdue",
365: "30d → 14d → 7d → Day-of → Overdue",
}
// FrequencyNames maps interval days to frequency names for display
var FrequencyNames = map[int]string{
0: "Once",
1: "Daily",
7: "Weekly",
14: "Bi-Weekly",
30: "Monthly",
90: "Quarterly",
180: "Semi-Annually",
365: "Annually",
}
// OrderedFrequencies defines the display order for frequencies
var OrderedFrequencies = []int{0, 1, 7, 14, 30, 90, 180, 365}

View 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"
}

View File

@@ -0,0 +1,524 @@
package notifications
import (
"fmt"
"math/rand"
"slices"
"testing"
"time"
)
// TestReminderScheduleWith100RandomTasks creates 100 tasks with random frequencies
// and validates that notifications are sent on exactly the right days according to
// the current FrequencySchedules and OverdueConfig.
//
// This test is dynamic - it reads from the config, so if the config changes,
// the test will still validate the correct behavior.
func TestReminderScheduleWith100RandomTasks(t *testing.T) {
// Seed random for reproducibility in tests (use current time for variety)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Available frequency intervals (from OrderedFrequencies)
// We'll also add some custom intervals to test the tier mapping
frequencyIntervals := []int{0, 1, 7, 14, 30, 90, 180, 365}
customIntervals := []int{5, 10, 20, 45, 120, 200, 400} // Custom intervals to test tier mapping
allIntervals := append(frequencyIntervals, customIntervals...)
// Base date for tests - use a fixed date for deterministic behavior
baseDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Track test statistics
tasksCreated := 0
daysSimulated := 0
notificationsSent := 0
notificationsExpected := 0
for i := 0; i < 100; i++ {
// Random frequency
intervalIdx := rng.Intn(len(allIntervals))
intervalDays := allIntervals[intervalIdx]
// Random due date offset (-30 to +30 days from base date)
dueDateOffset := rng.Intn(61) - 30
dueDate := baseDate.AddDate(0, 0, dueDateOffset)
// Create frequency pointer (nil for "Once" which is interval 0)
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
tasksCreated++
// Get the expected schedule for this frequency
expectedSchedule := GetScheduleForFrequency(frequencyDays)
expectedOverdueDays := GetOverdueReminderDays()
// Simulate each day from 45 days before to 15 days after due date
for dayOffset := -45; dayOffset <= 15; dayOffset++ {
today := dueDate.AddDate(0, 0, -dayOffset) // dayOffset is days until due, so we subtract
daysSimulated++
// Get the reminder stage for today
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
// Calculate what we expect
expectedStage := calculateExpectedStage(dayOffset, expectedSchedule, expectedOverdueDays)
if stage != expectedStage {
t.Errorf("Task %d (interval=%d, dueDate=%s): on %s (dayOffset=%d), got stage=%q, expected=%q",
i+1, intervalDays, dueDate.Format("2006-01-02"),
today.Format("2006-01-02"), dayOffset, stage, expectedStage)
}
if stage != "" {
notificationsSent++
}
if expectedStage != "" {
notificationsExpected++
}
}
}
t.Logf("Test Summary:")
t.Logf(" Tasks created: %d", tasksCreated)
t.Logf(" Days simulated: %d", daysSimulated)
t.Logf(" Notifications sent: %d", notificationsSent)
t.Logf(" Notifications expected: %d", notificationsExpected)
if notificationsSent != notificationsExpected {
t.Errorf("Notification count mismatch: sent=%d, expected=%d", notificationsSent, notificationsExpected)
}
}
// calculateExpectedStage determines what stage should be returned based on the config
func calculateExpectedStage(dayOffset int, preReminderDays []int, overdueDays []int) string {
if dayOffset > 0 {
// Task is in the future (dayOffset days until due)
if slices.Contains(preReminderDays, dayOffset) {
return formatDaysBeforeStage(dayOffset)
}
return ""
} else if dayOffset == 0 {
// Task is due today
return "day_of"
} else {
// Task is overdue (-dayOffset days overdue)
daysOverdue := -dayOffset
if slices.Contains(overdueDays, daysOverdue) {
return formatOverdueStage(daysOverdue)
}
return ""
}
}
// TestEachFrequencySchedule validates each frequency's schedule explicitly
func TestEachFrequencySchedule(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Test each configured frequency
for intervalDays, expectedSchedule := range FrequencySchedules {
t.Run(fmt.Sprintf("Interval_%d_days", intervalDays), func(t *testing.T) {
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
// Verify all expected reminder days trigger notifications
for _, reminderDay := range expectedSchedule {
today := dueDate.AddDate(0, 0, -reminderDay)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
expectedStage := formatDaysBeforeStage(reminderDay)
if stage != expectedStage {
t.Errorf("Reminder %d days before: got %q, expected %q", reminderDay, stage, expectedStage)
}
}
// Verify days NOT in the schedule don't trigger notifications (before due date)
for day := 1; day <= 45; day++ {
if !slices.Contains(expectedSchedule, day) {
today := dueDate.AddDate(0, 0, -day)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
if stage != "" {
t.Errorf("Day %d before due should NOT send notification for interval %d, but got %q",
day, intervalDays, stage)
}
}
}
})
}
}
// TestOverdueReminderSchedule validates the overdue tapering logic
func TestOverdueReminderSchedule(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Use nil frequency (Once) - overdue logic is the same for all frequencies
var frequencyDays *int
// Get expected overdue days from config
expectedOverdueDays := GetOverdueReminderDays()
t.Logf("OverdueConfig: DailyReminderDays=%d, TaperIntervalDays=%d, MaxOverdueDays=%d",
OverdueConfig.DailyReminderDays, OverdueConfig.TaperIntervalDays, OverdueConfig.MaxOverdueDays)
t.Logf("Expected overdue reminder days: %v", expectedOverdueDays)
// Test each day from 1 to MaxOverdueDays + 5
for daysOverdue := 1; daysOverdue <= OverdueConfig.MaxOverdueDays+5; daysOverdue++ {
today := dueDate.AddDate(0, 0, daysOverdue)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
shouldSend := slices.Contains(expectedOverdueDays, daysOverdue)
expectedStage := ""
if shouldSend {
expectedStage = formatOverdueStage(daysOverdue)
}
if stage != expectedStage {
t.Errorf("Day %d overdue: got %q, expected %q (shouldSend=%v)",
daysOverdue, stage, expectedStage, shouldSend)
}
}
}
// TestCustomIntervalTierMapping validates that custom intervals map to correct tiers
func TestCustomIntervalTierMapping(t *testing.T) {
testCases := []struct {
customInterval int
expectedTier int
expectedMessage string
}{
{2, 7, "2 days should map to Weekly (7) tier"},
{5, 7, "5 days should map to Weekly (7) tier"},
{8, 14, "8 days should map to Bi-Weekly (14) tier"},
{10, 14, "10 days should map to Bi-Weekly (14) tier"},
{20, 30, "20 days should map to Monthly (30) tier"},
{45, 90, "45 days should map to Quarterly (90) tier"},
{100, 180, "100 days should map to Semi-Annually (180) tier"},
{200, 365, "200 days should map to Annually (365) tier"},
{500, 365, "500 days should map to Annually (365) tier - fallback"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("CustomInterval_%d", tc.customInterval), func(t *testing.T) {
schedule := GetScheduleForCustomInterval(tc.customInterval)
expectedSchedule := FrequencySchedules[tc.expectedTier]
if !slices.Equal(schedule, expectedSchedule) {
t.Errorf("%s: got schedule %v, expected %v (tier %d)",
tc.expectedMessage, schedule, expectedSchedule, tc.expectedTier)
}
})
}
}
// TestDayOfNotification ensures day-of notifications always fire
func TestDayOfNotification(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
today := dueDate // Same day
// Test all frequency types - day-of should always fire
for intervalDays := range FrequencySchedules {
t.Run(fmt.Sprintf("DayOf_Interval_%d", intervalDays), func(t *testing.T) {
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
if stage != "day_of" {
t.Errorf("Day-of notification for interval %d: got %q, expected \"day_of\"",
intervalDays, stage)
}
})
}
}
// TestNoNotificationAfterMaxOverdue ensures no notifications after MaxOverdueDays
func TestNoNotificationAfterMaxOverdue(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
var frequencyDays *int // Once frequency
// Test days beyond MaxOverdueDays
for daysOverdue := OverdueConfig.MaxOverdueDays + 1; daysOverdue <= OverdueConfig.MaxOverdueDays+30; daysOverdue++ {
today := dueDate.AddDate(0, 0, daysOverdue)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
if stage != "" {
t.Errorf("Day %d overdue (beyond MaxOverdueDays=%d): should not send notification, but got %q",
daysOverdue, OverdueConfig.MaxOverdueDays, stage)
}
}
}
// TestNoNotificationTooFarInFuture ensures no notifications for tasks far in the future
func TestNoNotificationTooFarInFuture(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Get the maximum pre-reminder days from all schedules
maxPreReminderDays := 0
for _, schedule := range FrequencySchedules {
for _, days := range schedule {
if days > maxPreReminderDays {
maxPreReminderDays = days
}
}
}
// Test for each frequency type - days beyond their schedule should not trigger
for intervalDays, schedule := range FrequencySchedules {
t.Run(fmt.Sprintf("FarFuture_Interval_%d", intervalDays), func(t *testing.T) {
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
// Find the max reminder day for this frequency
maxForFrequency := 0
for _, d := range schedule {
if d > maxForFrequency {
maxForFrequency = d
}
}
// Test days beyond the max reminder day for this frequency
for daysBefore := maxForFrequency + 1; daysBefore <= maxPreReminderDays+10; daysBefore++ {
// Skip if this day is actually in the schedule
if slices.Contains(schedule, daysBefore) {
continue
}
today := dueDate.AddDate(0, 0, -daysBefore)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
if stage != "" {
t.Errorf("Day %d before due (beyond schedule max=%d for interval %d): should not send, but got %q",
daysBefore, maxForFrequency, intervalDays, stage)
}
}
})
}
}
// TestConfigConsistency validates that the configuration is internally consistent
func TestConfigConsistency(t *testing.T) {
// Verify GetOverdueReminderDays matches ShouldSendOverdueReminder
overdueDays := GetOverdueReminderDays()
for day := 1; day <= OverdueConfig.MaxOverdueDays+5; day++ {
shouldSend := ShouldSendOverdueReminder(day)
isInList := slices.Contains(overdueDays, day)
if shouldSend != isInList {
t.Errorf("Inconsistency at day %d: ShouldSendOverdueReminder=%v, but day in GetOverdueReminderDays=%v",
day, shouldSend, isInList)
}
}
// Verify all frequencies have 0 (day-of) in their schedule
for intervalDays, schedule := range FrequencySchedules {
if !slices.Contains(schedule, 0) {
t.Errorf("Frequency %d days is missing day-of (0) in schedule: %v", intervalDays, schedule)
}
}
// Verify schedules are sorted in descending order (most days first)
for intervalDays, schedule := range FrequencySchedules {
for i := 0; i < len(schedule)-1; i++ {
if schedule[i] < schedule[i+1] {
t.Errorf("Frequency %d schedule is not in descending order: %v", intervalDays, schedule)
break
}
}
}
}
// TestStageFormatting validates the stage string formatting
func TestStageFormatting(t *testing.T) {
testCases := []struct {
daysBefore int
expectedStage string
}{
{0, "day_of"},
{1, "reminder_1d"},
{3, "reminder_3d"},
{7, "reminder_7d"},
{14, "reminder_14d"},
{30, "reminder_30d"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Format_%d_days_before", tc.daysBefore), func(t *testing.T) {
stage := formatDaysBeforeStage(tc.daysBefore)
if stage != tc.expectedStage {
t.Errorf("formatDaysBeforeStage(%d) = %q, expected %q", tc.daysBefore, stage, tc.expectedStage)
}
})
}
// Test overdue formatting
overdueTestCases := []struct {
daysOverdue int
expectedStage string
}{
{1, "overdue_1"},
{3, "overdue_3"},
{7, "overdue_7"},
{14, "overdue_14"},
}
for _, tc := range overdueTestCases {
t.Run(fmt.Sprintf("Format_%d_days_overdue", tc.daysOverdue), func(t *testing.T) {
stage := formatOverdueStage(tc.daysOverdue)
if stage != tc.expectedStage {
t.Errorf("formatOverdueStage(%d) = %q, expected %q", tc.daysOverdue, stage, tc.expectedStage)
}
})
}
}
// BenchmarkGetReminderStageForToday benchmarks the main function
func BenchmarkGetReminderStageForToday(b *testing.B) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
today := time.Date(2025, 6, 12, 0, 0, 0, 0, time.UTC) // 3 days before
intervalDays := 30
frequencyDays := &intervalDays
b.ResetTimer()
for i := 0; i < b.N; i++ {
GetReminderStageForToday(dueDate, frequencyDays, today)
}
}
// TestReminderScheduleTableDriven uses the config to generate a comprehensive
// table-driven test that validates all expected notification days
func TestReminderScheduleTableDriven(t *testing.T) {
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Build test cases from config
for intervalDays, preSchedule := range FrequencySchedules {
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
freqName := FrequencyNames[intervalDays]
if freqName == "" {
freqName = fmt.Sprintf("Custom_%d", intervalDays)
}
// Test pre-due reminders
for _, daysBefore := range preSchedule {
t.Run(fmt.Sprintf("%s_PreReminder_%dd", freqName, daysBefore), func(t *testing.T) {
today := dueDate.AddDate(0, 0, -daysBefore)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
expected := formatDaysBeforeStage(daysBefore)
if stage != expected {
t.Errorf("got %q, expected %q", stage, expected)
}
})
}
// Test overdue reminders (same for all frequencies)
overdueDays := GetOverdueReminderDays()
for _, daysOverdue := range overdueDays {
t.Run(fmt.Sprintf("%s_Overdue_%dd", freqName, daysOverdue), func(t *testing.T) {
today := dueDate.AddDate(0, 0, daysOverdue)
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
expected := formatOverdueStage(daysOverdue)
if stage != expected {
t.Errorf("got %q, expected %q", stage, expected)
}
})
}
}
}
// TestFullSimulation100Tasks is the main comprehensive test that simulates
// 100 random tasks over the full date range
func TestFullSimulation100Tasks(t *testing.T) {
// Use a fixed seed for reproducible tests
rng := rand.New(rand.NewSource(42))
// All available frequencies including custom
frequencies := []int{0, 1, 7, 14, 30, 90, 180, 365, 5, 10, 20, 45, 100, 200, 500}
baseDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
// Statistics
totalDays := 0
totalNotifications := 0
notificationsByStage := make(map[string]int)
for taskIdx := 0; taskIdx < 100; taskIdx++ {
// Random frequency
intervalDays := frequencies[rng.Intn(len(frequencies))]
// Random due date (within 60-day range around base)
dueDateOffset := rng.Intn(61) - 30
dueDate := baseDate.AddDate(0, 0, dueDateOffset)
var frequencyDays *int
if intervalDays > 0 {
frequencyDays = &intervalDays
}
// Get expected schedules from config
preSchedule := GetScheduleForFrequency(frequencyDays)
overdueDays := GetOverdueReminderDays()
// Simulate 45 days before to 15 days after due date
for dayOffset := -45; dayOffset <= 15; dayOffset++ {
// dayOffset: negative = future (before due), positive = past (overdue)
// So if dayOffset = -10, we're 10 days BEFORE due date
// If dayOffset = 5, we're 5 days AFTER due date (overdue)
today := dueDate.AddDate(0, 0, dayOffset)
totalDays++
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
// Calculate expected stage
var expectedStage string
if dayOffset < 0 {
// Before due date: check pre-reminders
daysBefore := -dayOffset
if slices.Contains(preSchedule, daysBefore) {
expectedStage = formatDaysBeforeStage(daysBefore)
}
} else if dayOffset == 0 {
// Day of
expectedStage = "day_of"
} else {
// Overdue
if slices.Contains(overdueDays, dayOffset) {
expectedStage = formatOverdueStage(dayOffset)
}
}
if stage != expectedStage {
t.Errorf("Task %d (freq=%d, due=%s): day %s (offset=%d): got %q, expected %q",
taskIdx+1, intervalDays, dueDate.Format("2006-01-02"),
today.Format("2006-01-02"), dayOffset, stage, expectedStage)
}
if stage != "" {
totalNotifications++
notificationsByStage[stage]++
}
}
}
t.Logf("Full Simulation Results:")
t.Logf(" Total tasks: 100")
t.Logf(" Total days simulated: %d", totalDays)
t.Logf(" Total notifications: %d", totalNotifications)
t.Logf(" Notifications by stage:")
for stage, count := range notificationsByStage {
t.Logf(" %s: %d", stage, count)
}
}