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:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
"github.com/treytartt/casera-api/internal/notifications"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
)
|
||||
|
||||
@@ -412,10 +413,12 @@ func (h *AdminLookupHandler) DeletePriority(c echo.Context) error {
|
||||
// ========== Task Frequencies ==========
|
||||
|
||||
type TaskFrequencyResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Days *int `json:"days"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Days *int `json:"days"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
NotificationSchedule string `json:"notification_schedule"`
|
||||
ReminderDays []int `json:"reminder_days"`
|
||||
}
|
||||
|
||||
type CreateUpdateFrequencyRequest struct {
|
||||
@@ -432,15 +435,34 @@ func (h *AdminLookupHandler) ListFrequencies(c echo.Context) error {
|
||||
|
||||
responses := make([]TaskFrequencyResponse, len(frequencies))
|
||||
for i, f := range frequencies {
|
||||
// Get notification schedule for this frequency
|
||||
schedule := notifications.GetScheduleForFrequency(f.Days)
|
||||
humanReadable := notifications.GetHumanReadableSchedule(f.Days)
|
||||
|
||||
responses[i] = TaskFrequencyResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Days: f.Days,
|
||||
DisplayOrder: f.DisplayOrder,
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Days: f.Days,
|
||||
DisplayOrder: f.DisplayOrder,
|
||||
NotificationSchedule: humanReadable,
|
||||
ReminderDays: schedule,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
|
||||
// Include overdue policy in response
|
||||
overduePolicy := map[string]interface{}{
|
||||
"daily_reminder_days": notifications.OverdueConfig.DailyReminderDays,
|
||||
"taper_interval_days": notifications.OverdueConfig.TaperIntervalDays,
|
||||
"max_overdue_days": notifications.OverdueConfig.MaxOverdueDays,
|
||||
"description": "Daily for first " + strconv.Itoa(notifications.OverdueConfig.DailyReminderDays) + " days, then every " + strconv.Itoa(notifications.OverdueConfig.TaperIntervalDays) + " days, stops after " + strconv.Itoa(notifications.OverdueConfig.MaxOverdueDays) + " days",
|
||||
"overdue_days": notifications.GetOverdueReminderDays(),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"data": responses,
|
||||
"total": len(responses),
|
||||
"overdue_policy": overduePolicy,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
|
||||
@@ -464,11 +486,17 @@ func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
|
||||
// Refresh cache after creating
|
||||
h.refreshFrequenciesCache(c.Request().Context())
|
||||
|
||||
// Get notification schedule for this frequency
|
||||
schedule := notifications.GetScheduleForFrequency(frequency.Days)
|
||||
humanReadable := notifications.GetHumanReadableSchedule(frequency.Days)
|
||||
|
||||
return c.JSON(http.StatusCreated, TaskFrequencyResponse{
|
||||
ID: frequency.ID,
|
||||
Name: frequency.Name,
|
||||
Days: frequency.Days,
|
||||
DisplayOrder: frequency.DisplayOrder,
|
||||
ID: frequency.ID,
|
||||
Name: frequency.Name,
|
||||
Days: frequency.Days,
|
||||
DisplayOrder: frequency.DisplayOrder,
|
||||
NotificationSchedule: humanReadable,
|
||||
ReminderDays: schedule,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -504,11 +532,17 @@ func (h *AdminLookupHandler) UpdateFrequency(c echo.Context) error {
|
||||
// Refresh cache after updating
|
||||
h.refreshFrequenciesCache(c.Request().Context())
|
||||
|
||||
// Get notification schedule for this frequency
|
||||
schedule := notifications.GetScheduleForFrequency(frequency.Days)
|
||||
humanReadable := notifications.GetHumanReadableSchedule(frequency.Days)
|
||||
|
||||
return c.JSON(http.StatusOK, TaskFrequencyResponse{
|
||||
ID: frequency.ID,
|
||||
Name: frequency.Name,
|
||||
Days: frequency.Days,
|
||||
DisplayOrder: frequency.DisplayOrder,
|
||||
ID: frequency.ID,
|
||||
Name: frequency.Name,
|
||||
Days: frequency.Days,
|
||||
DisplayOrder: frequency.DisplayOrder,
|
||||
NotificationSchedule: humanReadable,
|
||||
ReminderDays: schedule,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -770,5 +804,73 @@ func (h *AdminLookupHandler) DeleteSpecialty(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Specialty deleted successfully"})
|
||||
}
|
||||
|
||||
// ========== Notification Schedules ==========
|
||||
|
||||
// FrequencyScheduleResponse represents a frequency with its notification schedule
|
||||
type FrequencyScheduleResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Days *int `json:"days"`
|
||||
NotificationSchedule string `json:"notification_schedule"`
|
||||
ReminderDays []int `json:"reminder_days"`
|
||||
}
|
||||
|
||||
// OverduePolicyResponse represents the overdue reminder policy
|
||||
type OverduePolicyResponse struct {
|
||||
DailyReminderDays int `json:"daily_reminder_days"`
|
||||
TaperIntervalDays int `json:"taper_interval_days"`
|
||||
MaxOverdueDays int `json:"max_overdue_days"`
|
||||
Description string `json:"description"`
|
||||
OverdueDays []int `json:"overdue_days"`
|
||||
}
|
||||
|
||||
// NotificationSchedulesResponse is the full response for notification schedules
|
||||
type NotificationSchedulesResponse struct {
|
||||
Frequencies []FrequencyScheduleResponse `json:"frequencies"`
|
||||
OverduePolicy OverduePolicyResponse `json:"overdue_policy"`
|
||||
}
|
||||
|
||||
// GetNotificationSchedules handles GET /api/admin/lookups/notification-schedules
|
||||
// Returns the notification schedule configuration for all frequencies
|
||||
func (h *AdminLookupHandler) GetNotificationSchedules(c echo.Context) error {
|
||||
// Get all frequencies from database
|
||||
var frequencies []models.TaskFrequency
|
||||
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch frequencies"})
|
||||
}
|
||||
|
||||
// Build frequency schedules with notification info
|
||||
frequencyResponses := make([]FrequencyScheduleResponse, len(frequencies))
|
||||
for i, f := range frequencies {
|
||||
// Get the notification schedule for this frequency's days
|
||||
schedule := notifications.GetScheduleForFrequency(f.Days)
|
||||
humanReadable := notifications.GetHumanReadableSchedule(f.Days)
|
||||
|
||||
frequencyResponses[i] = FrequencyScheduleResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Days: f.Days,
|
||||
NotificationSchedule: humanReadable,
|
||||
ReminderDays: schedule,
|
||||
}
|
||||
}
|
||||
|
||||
// Build overdue policy response
|
||||
overduePolicy := OverduePolicyResponse{
|
||||
DailyReminderDays: notifications.OverdueConfig.DailyReminderDays,
|
||||
TaperIntervalDays: notifications.OverdueConfig.TaperIntervalDays,
|
||||
MaxOverdueDays: notifications.OverdueConfig.MaxOverdueDays,
|
||||
Description: "Daily for first " + strconv.Itoa(notifications.OverdueConfig.DailyReminderDays) + " days, then every " + strconv.Itoa(notifications.OverdueConfig.TaperIntervalDays) + " days, stops after " + strconv.Itoa(notifications.OverdueConfig.MaxOverdueDays) + " days",
|
||||
OverdueDays: notifications.GetOverdueReminderDays(),
|
||||
}
|
||||
|
||||
response := NotificationSchedulesResponse{
|
||||
Frequencies: frequencyResponses,
|
||||
OverduePolicy: overduePolicy,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Ensure dto import is used
|
||||
var _ = dto.PaginationParams{}
|
||||
|
||||
@@ -274,6 +274,9 @@ func SetupRoutes(router *echo.Echo, db *gorm.DB, cfg *config.Config, deps *Depen
|
||||
frequencies.DELETE("/:id", lookupHandler.DeleteFrequency)
|
||||
}
|
||||
|
||||
// Notification Schedules (read-only, shows schedule for each frequency)
|
||||
protected.GET("/lookups/notification-schedules", lookupHandler.GetNotificationSchedules)
|
||||
|
||||
// Residence Types
|
||||
residenceTypes := protected.Group("/lookups/residence-types")
|
||||
{
|
||||
|
||||
91
internal/models/reminder_log.go
Normal file
91
internal/models/reminder_log.go
Normal 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"
|
||||
}
|
||||
63
internal/notifications/reminder_config.go
Normal file
63
internal/notifications/reminder_config.go
Normal 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}
|
||||
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"
|
||||
}
|
||||
524
internal/notifications/reminder_schedule_test.go
Normal file
524
internal/notifications/reminder_schedule_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
125
internal/repositories/reminder_repo.go
Normal file
125
internal/repositories/reminder_repo.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// ReminderRepository handles database operations for task reminder logs
|
||||
type ReminderRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewReminderRepository creates a new reminder repository
|
||||
func NewReminderRepository(db *gorm.DB) *ReminderRepository {
|
||||
return &ReminderRepository{db: db}
|
||||
}
|
||||
|
||||
// HasSentReminder checks if a reminder has already been sent for the given
|
||||
// task, user, due date, and reminder stage.
|
||||
func (r *ReminderRepository) HasSentReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage) (bool, error) {
|
||||
// Normalize to date only
|
||||
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
var count int64
|
||||
err := r.db.Model(&models.TaskReminderLog{}).
|
||||
Where("task_id = ? AND user_id = ? AND due_date = ? AND reminder_stage = ?",
|
||||
taskID, userID, dueDateOnly, stage).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// LogReminder records that a reminder was sent.
|
||||
// Returns the created log entry or an error if the reminder was already sent
|
||||
// (unique constraint violation).
|
||||
func (r *ReminderRepository) LogReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error) {
|
||||
// Normalize to date only
|
||||
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
log := &models.TaskReminderLog{
|
||||
TaskID: taskID,
|
||||
UserID: userID,
|
||||
DueDate: dueDateOnly,
|
||||
ReminderStage: stage,
|
||||
SentAt: time.Now().UTC(),
|
||||
NotificationID: notificationID,
|
||||
}
|
||||
|
||||
err := r.db.Create(log).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// GetSentRemindersForTask returns all reminder logs for a specific task and user.
|
||||
func (r *ReminderRepository) GetSentRemindersForTask(taskID, userID uint) ([]models.TaskReminderLog, error) {
|
||||
var logs []models.TaskReminderLog
|
||||
err := r.db.Where("task_id = ? AND user_id = ?", taskID, userID).
|
||||
Order("sent_at DESC").
|
||||
Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
// GetSentRemindersForDueDate returns all reminder logs for a specific task,
|
||||
// user, and due date.
|
||||
func (r *ReminderRepository) GetSentRemindersForDueDate(taskID, userID uint, dueDate time.Time) ([]models.TaskReminderLog, error) {
|
||||
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
var logs []models.TaskReminderLog
|
||||
err := r.db.Where("task_id = ? AND user_id = ? AND due_date = ?",
|
||||
taskID, userID, dueDateOnly).
|
||||
Order("sent_at DESC").
|
||||
Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
// CleanupOldLogs removes reminder logs older than the specified number of days.
|
||||
// This helps keep the table from growing indefinitely.
|
||||
func (r *ReminderRepository) CleanupOldLogs(daysOld int) (int64, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -daysOld)
|
||||
|
||||
result := r.db.Where("sent_at < ?", cutoff).
|
||||
Delete(&models.TaskReminderLog{})
|
||||
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
// GetRecentReminderStats returns statistics about recent reminders sent.
|
||||
// Useful for admin/monitoring purposes.
|
||||
func (r *ReminderRepository) GetRecentReminderStats(sinceHours int) (map[string]int64, error) {
|
||||
since := time.Now().UTC().Add(-time.Duration(sinceHours) * time.Hour)
|
||||
|
||||
stats := make(map[string]int64)
|
||||
|
||||
// Count by stage
|
||||
rows, err := r.db.Model(&models.TaskReminderLog{}).
|
||||
Select("reminder_stage, COUNT(*) as count").
|
||||
Where("sent_at >= ?", since).
|
||||
Group("reminder_stage").
|
||||
Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var stage string
|
||||
var count int64
|
||||
if err := rows.Scan(&stage, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats[stage] = count
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type TaskFilterOptions struct {
|
||||
PreloadAssignedTo bool
|
||||
PreloadResidence bool
|
||||
PreloadCompletions bool // Minimal: just id, task_id, completed_at
|
||||
PreloadFrequency bool // For smart notifications
|
||||
}
|
||||
|
||||
// applyFilterOptions applies the filter options to a query.
|
||||
@@ -88,6 +89,9 @@ func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptio
|
||||
return db.Select("id", "task_id", "completed_at")
|
||||
})
|
||||
}
|
||||
if opts.PreloadFrequency {
|
||||
query = query.Preload("Frequency")
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -209,6 +213,32 @@ func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Tas
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// GetActiveTasksForUsers returns all active, non-completed tasks for the specified users.
|
||||
// This is used by the smart notification system to evaluate all tasks for potential reminders.
|
||||
// It includes tasks that are overdue, due soon, or upcoming - the caller determines
|
||||
// which reminders to send based on the task's frequency and due date.
|
||||
func (r *TaskRepository) GetActiveTasksForUsers(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
|
||||
// Get all active, non-completed tasks
|
||||
query := r.db.Model(&models.Task{}).
|
||||
Scopes(task.ScopeActive, task.ScopeNotCompleted)
|
||||
|
||||
// Include in-progress tasks if specified
|
||||
if !opts.IncludeInProgress {
|
||||
query = query.Scopes(task.ScopeNotInProgress)
|
||||
}
|
||||
|
||||
// Apply filters and preloads
|
||||
query = r.applyFilterOptions(query, opts)
|
||||
|
||||
// Order by due date for consistent processing
|
||||
query = query.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
|
||||
|
||||
err := query.Find(&tasks).Error
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// === Task CRUD ===
|
||||
|
||||
// FindByID finds a task by ID with preloaded relations
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
"github.com/treytartt/casera-api/internal/notifications"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
@@ -19,18 +20,21 @@ import (
|
||||
|
||||
// Task types
|
||||
const (
|
||||
TypeTaskReminder = "notification:task_reminder"
|
||||
TypeOverdueReminder = "notification:overdue_reminder"
|
||||
TypeDailyDigest = "notification:daily_digest"
|
||||
TypeSendEmail = "email:send"
|
||||
TypeSendPush = "push:send"
|
||||
TypeOnboardingEmails = "email:onboarding"
|
||||
TypeTaskReminder = "notification:task_reminder"
|
||||
TypeOverdueReminder = "notification:overdue_reminder"
|
||||
TypeSmartReminder = "notification:smart_reminder" // Frequency-aware reminders
|
||||
TypeDailyDigest = "notification:daily_digest"
|
||||
TypeSendEmail = "email:send"
|
||||
TypeSendPush = "push:send"
|
||||
TypeOnboardingEmails = "email:onboarding"
|
||||
TypeReminderLogCleanup = "maintenance:reminder_log_cleanup"
|
||||
)
|
||||
|
||||
// Handler handles background job processing
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
taskRepo *repositories.TaskRepository
|
||||
reminderRepo *repositories.ReminderRepository
|
||||
pushClient *push.Client
|
||||
emailService *services.EmailService
|
||||
notificationService *services.NotificationService
|
||||
@@ -49,6 +53,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
|
||||
return &Handler{
|
||||
db: db,
|
||||
taskRepo: repositories.NewTaskRepository(db),
|
||||
reminderRepo: repositories.NewReminderRepository(db),
|
||||
pushClient: pushClient,
|
||||
emailService: emailService,
|
||||
notificationService: notificationService,
|
||||
@@ -553,3 +558,179 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleSmartReminder processes frequency-aware task reminders.
|
||||
// Unlike the old HandleTaskReminder and HandleOverdueReminder, this handler:
|
||||
// 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
|
||||
// 2. Tracks sent reminders to prevent duplicates
|
||||
// 3. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14)
|
||||
func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) error {
|
||||
log.Info().Msg("Processing smart task reminders...")
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
currentHour := now.Hour()
|
||||
|
||||
// Use the task_due_soon hour setting for smart reminders
|
||||
systemDefaultHour := h.config.Worker.TaskReminderHour
|
||||
|
||||
log.Info().
|
||||
Int("current_hour", currentHour).
|
||||
Int("system_default_hour", systemDefaultHour).
|
||||
Msg("Smart reminder check")
|
||||
|
||||
// Step 1: Find users who should receive notifications THIS hour
|
||||
var eligibleUserIDs []uint
|
||||
|
||||
query := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_due_soon = true OR task_overdue = true")
|
||||
|
||||
if currentHour == systemDefaultHour {
|
||||
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
|
||||
} else {
|
||||
query = query.Where("task_due_soon_hour = ?", currentHour)
|
||||
}
|
||||
|
||||
if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query eligible users for smart reminders")
|
||||
return err
|
||||
}
|
||||
|
||||
if len(eligibleUserIDs) == 0 {
|
||||
log.Debug().Int("hour", currentHour).Msg("No users scheduled for smart reminders this hour")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for smart reminders")
|
||||
|
||||
// Step 2: Query all active tasks for eligible users
|
||||
opts := repositories.TaskFilterOptions{
|
||||
UserIDs: eligibleUserIDs,
|
||||
IncludeInProgress: true,
|
||||
PreloadResidence: true,
|
||||
PreloadCompletions: true,
|
||||
PreloadFrequency: true,
|
||||
}
|
||||
|
||||
// Get all active, non-completed tasks
|
||||
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query active tasks")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users")
|
||||
|
||||
// Step 3: Process each task
|
||||
var sentCount, skippedCount int
|
||||
|
||||
for _, t := range activeTasks {
|
||||
// Determine which user to notify
|
||||
var userID uint
|
||||
if t.AssignedToID != nil {
|
||||
userID = *t.AssignedToID
|
||||
} else if t.Residence.ID != 0 {
|
||||
userID = t.Residence.OwnerID
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user is in eligible list
|
||||
eligible := false
|
||||
for _, eligibleID := range eligibleUserIDs {
|
||||
if userID == eligibleID {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the effective due date (NextDueDate takes precedence for recurring tasks)
|
||||
var effectiveDate time.Time
|
||||
if t.NextDueDate != nil {
|
||||
effectiveDate = *t.NextDueDate
|
||||
} else if t.DueDate != nil {
|
||||
effectiveDate = *t.DueDate
|
||||
} else {
|
||||
// No due date, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Get frequency interval days
|
||||
var frequencyDays *int
|
||||
if t.Frequency != nil && t.Frequency.Days != nil {
|
||||
days := int(*t.Frequency.Days)
|
||||
frequencyDays = &days
|
||||
} else if t.CustomIntervalDays != nil {
|
||||
days := int(*t.CustomIntervalDays)
|
||||
frequencyDays = &days
|
||||
}
|
||||
|
||||
// Determine which reminder stage applies today
|
||||
stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today)
|
||||
if stage == "" {
|
||||
continue // No reminder needed today
|
||||
}
|
||||
|
||||
// Convert stage string to ReminderStage type
|
||||
reminderStage := models.ReminderStage(stage)
|
||||
|
||||
// Check if already sent
|
||||
alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Uint("task_id", t.ID).Msg("Failed to check reminder log")
|
||||
continue
|
||||
}
|
||||
|
||||
if alreadySent {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine notification type based on stage
|
||||
var notificationType models.NotificationType
|
||||
if stage == "day_of" || (len(stage) >= 8 && stage[:8] == "reminder") {
|
||||
notificationType = models.NotificationTaskDueSoon
|
||||
} else {
|
||||
notificationType = models.NotificationTaskOverdue
|
||||
}
|
||||
|
||||
// Send notification
|
||||
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, notificationType, &t); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to send smart reminder")
|
||||
continue
|
||||
}
|
||||
|
||||
// Log the reminder
|
||||
if _, err := h.reminderRepo.LogReminder(t.ID, userID, effectiveDate, reminderStage, nil); err != nil {
|
||||
log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder")
|
||||
}
|
||||
|
||||
sentCount++
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("sent", sentCount).
|
||||
Int("skipped_duplicates", skippedCount).
|
||||
Msg("Smart reminder notifications completed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat
|
||||
func (h *Handler) HandleReminderLogCleanup(ctx context.Context, task *asynq.Task) error {
|
||||
log.Info().Msg("Processing reminder log cleanup...")
|
||||
|
||||
// Clean up logs older than 90 days
|
||||
deleted, err := h.reminderRepo.CleanupOldLogs(90)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to cleanup old reminder logs")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int64("deleted", deleted).Msg("Reminder log cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user