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,91 @@
package models
import (
"fmt"
"time"
)
// ReminderStage represents the stage of a task reminder
type ReminderStage string
const (
// Pre-due date reminders (days before)
ReminderStage30Days ReminderStage = "reminder_30d"
ReminderStage14Days ReminderStage = "reminder_14d"
ReminderStage7Days ReminderStage = "reminder_7d"
ReminderStage3Days ReminderStage = "reminder_3d"
ReminderStage1Day ReminderStage = "reminder_1d"
ReminderStageDayOf ReminderStage = "day_of"
// Overdue reminders (days after)
ReminderStageOverdue1 ReminderStage = "overdue_1"
ReminderStageOverdue2 ReminderStage = "overdue_2"
ReminderStageOverdue3 ReminderStage = "overdue_3"
ReminderStageOverdue4 ReminderStage = "overdue_4"
ReminderStageOverdue7 ReminderStage = "overdue_7"
ReminderStageOverdue10 ReminderStage = "overdue_10"
ReminderStageOverdue13 ReminderStage = "overdue_13"
)
// GetReminderStageForDaysBefore returns the reminder stage for days before due date
func GetReminderStageForDaysBefore(days int) ReminderStage {
switch days {
case 30:
return ReminderStage30Days
case 14:
return ReminderStage14Days
case 7:
return ReminderStage7Days
case 3:
return ReminderStage3Days
case 1:
return ReminderStage1Day
case 0:
return ReminderStageDayOf
default:
// For any other number of days, create a custom stage
return ReminderStage(fmt.Sprintf("reminder_%dd", days))
}
}
// GetReminderStageForDaysOverdue returns the reminder stage for days overdue
func GetReminderStageForDaysOverdue(days int) ReminderStage {
switch days {
case 1:
return ReminderStageOverdue1
case 2:
return ReminderStageOverdue2
case 3:
return ReminderStageOverdue3
case 4:
return ReminderStageOverdue4
case 7:
return ReminderStageOverdue7
case 10:
return ReminderStageOverdue10
case 13:
return ReminderStageOverdue13
default:
// For any other overdue day, format as overdue_N
return ReminderStage(fmt.Sprintf("overdue_%d", days))
}
}
// TaskReminderLog tracks which reminders have been sent to prevent duplicates
type TaskReminderLog struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;not null;index:idx_reminderlog_task_user_date" json:"task_id"`
Task *Task `gorm:"foreignKey:TaskID" json:"-"`
UserID uint `gorm:"column:user_id;not null;index:idx_reminderlog_task_user_date" json:"user_id"`
User *User `gorm:"foreignKey:UserID" json:"-"`
DueDate time.Time `gorm:"column:due_date;type:date;not null;index:idx_reminderlog_task_user_date" json:"due_date"`
ReminderStage ReminderStage `gorm:"column:reminder_stage;size:20;not null" json:"reminder_stage"`
SentAt time.Time `gorm:"column:sent_at;default:CURRENT_TIMESTAMP;index:idx_reminderlog_sent_at" json:"sent_at"`
NotificationID *uint `gorm:"column:notification_id" json:"notification_id"`
Notification *Notification `gorm:"foreignKey:NotificationID" json:"-"`
}
// TableName returns the table name for GORM
func (TaskReminderLog) TableName() string {
return "task_reminderlog"
}