From 69206c69300ff8540f88cadf93f71be232717594 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 19 Dec 2025 23:03:28 -0600 Subject: [PATCH] Add smart notification reminder system with frequency-aware scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/src/app/(dashboard)/lookups/page.tsx | 324 ++++++++++- admin/src/lib/api.ts | 45 +- internal/admin/handlers/lookup_handler.go | 136 ++++- internal/admin/routes.go | 3 + internal/models/reminder_log.go | 91 +++ internal/notifications/reminder_config.go | 63 +++ internal/notifications/reminder_schedule.go | 198 +++++++ .../notifications/reminder_schedule_test.go | 524 ++++++++++++++++++ internal/repositories/reminder_repo.go | 125 +++++ internal/repositories/task_repo.go | 30 + internal/worker/jobs/handler.go | 193 ++++++- migrations/010_add_task_reminder_log.down.sql | 5 + migrations/010_add_task_reminder_log.up.sql | 24 + 13 files changed, 1733 insertions(+), 28 deletions(-) create mode 100644 internal/models/reminder_log.go create mode 100644 internal/notifications/reminder_config.go create mode 100644 internal/notifications/reminder_schedule.go create mode 100644 internal/notifications/reminder_schedule_test.go create mode 100644 internal/repositories/reminder_repo.go create mode 100644 migrations/010_add_task_reminder_log.down.sql create mode 100644 migrations/010_add_task_reminder_log.up.sql diff --git a/admin/src/app/(dashboard)/lookups/page.tsx b/admin/src/app/(dashboard)/lookups/page.tsx index 7863d3e..6ce9706 100644 --- a/admin/src/app/(dashboard)/lookups/page.tsx +++ b/admin/src/app/(dashboard)/lookups/page.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Pencil, Trash2, GripVertical, X, Check } from 'lucide-react'; +import { Plus, Pencil, Trash2, GripVertical, X, Check, Bell, Info } from 'lucide-react'; import { toast } from 'sonner'; -import { lookupsApi, type LookupItem, type CreateLookupRequest, type UpdateLookupRequest } from '@/lib/api'; +import { lookupsApi, type LookupItem, type CreateLookupRequest, type UpdateLookupRequest, type FrequencyItem, type OverduePolicy } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -34,11 +34,18 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; const lookupTabs = [ { key: 'categories', label: 'Task Categories', api: lookupsApi.categories }, { key: 'priorities', label: 'Task Priorities', api: lookupsApi.priorities }, - { key: 'frequencies', label: 'Task Frequencies', api: lookupsApi.frequencies }, + { key: 'frequencies', label: 'Task Frequencies', api: null }, // Handled separately { key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes }, { key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties }, ]; @@ -305,6 +312,311 @@ function LookupTable({ lookupKey, api }: LookupTableProps) { ); } +// Specialized table for frequencies that shows notification schedules +function FrequenciesTable() { + const queryClient = useQueryClient(); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + const [editingOrder, setEditingOrder] = useState(0); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(''); + const [newOrder, setNewOrder] = useState(0); + const [deleteItem, setDeleteItem] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['lookups', 'frequencies'], + queryFn: () => lookupsApi.frequencies.listWithPolicy(), + }); + + const items = data?.data ?? []; + const overduePolicy = data?.overdue_policy; + + const createMutation = useMutation({ + mutationFn: (data: CreateLookupRequest) => lookupsApi.frequencies.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] }); + setIsAdding(false); + setNewName(''); + setNewOrder(0); + toast.success('Frequency created successfully'); + }, + onError: () => { + toast.error('Failed to create frequency'); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateLookupRequest }) => lookupsApi.frequencies.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] }); + setEditingId(null); + toast.success('Frequency updated successfully'); + }, + onError: () => { + toast.error('Failed to update frequency'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => lookupsApi.frequencies.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] }); + setDeleteItem(null); + toast.success('Frequency deleted successfully'); + }, + onError: () => { + toast.error('Failed to delete frequency'); + }, + }); + + const handleEdit = (item: FrequencyItem) => { + setEditingId(item.id); + setEditingName(item.name); + setEditingOrder(item.display_order); + }; + + const handleSave = () => { + if (!editingId) return; + updateMutation.mutate({ + id: editingId, + data: { + name: editingName, + display_order: editingOrder, + }, + }); + }; + + const handleCreate = () => { + if (!newName.trim()) return; + createMutation.mutate({ + name: newName, + display_order: newOrder, + }); + }; + + const sortedItems = [...items].sort((a, b) => a.display_order - b.display_order); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+ {/* Overdue Policy Info */} + {overduePolicy && ( +
+
+ + Overdue Reminder Policy +
+

{overduePolicy.description}

+
+ {overduePolicy.overdue_days.map((day) => ( + + Day {day} + + ))} +
+
+ )} + +
+ +
+ + + + + # + Name + Notification Schedule + Order + Actions + + + + {isAdding && ( + + + + + + setNewName(e.target.value)} + placeholder="Enter name..." + className="h-8" + autoFocus + /> + + + Auto-assigned based on interval + + + setNewOrder(parseInt(e.target.value) || 0)} + className="h-8 w-16" + /> + + +
+ + +
+
+
+ )} + {sortedItems.map((item) => ( + + + + + + {editingId === item.id ? ( + setEditingName(e.target.value)} + className="h-8" + /> + ) : ( +
+
{item.name}
+ {item.days !== null && ( +
+ {item.days} day{item.days !== 1 ? 's' : ''} interval +
+ )} +
+ )} +
+ + + + +
+ + {item.notification_schedule} +
+
+ +

Reminder days before due: {item.reminder_days.join(', ') || 'Day-of only'}

+
+
+
+
+ + {editingId === item.id ? ( + setEditingOrder(parseInt(e.target.value) || 0)} + className="h-8 w-16" + /> + ) : ( + item.display_order + )} + + + {editingId === item.id ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ))} + {sortedItems.length === 0 && !isAdding && ( + + + No frequencies found. Click "Add Item" to create one. + + + )} +
+
+ + !open && setDeleteItem(null)}> + + + Delete Frequency + + Are you sure you want to delete "{deleteItem?.name}"? This action cannot be undone. + + + + Cancel + deleteItem && deleteMutation.mutate(deleteItem.id)} + className="bg-red-600 hover:bg-red-700" + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + +
+ ); +} + export default function LookupsPage() { return (
@@ -333,7 +645,11 @@ export default function LookupsPage() { {lookupTabs.map((tab) => ( - + {tab.key === 'frequencies' ? ( + + ) : ( + + )} ))} diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 41e0f94..11a2bec 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -473,6 +473,29 @@ export interface LookupItem { color?: string; } +// Frequency Item with notification schedule +export interface FrequencyItem extends LookupItem { + days: number | null; + notification_schedule: string; + reminder_days: number[]; +} + +// Overdue Policy +export interface OverduePolicy { + daily_reminder_days: number; + taper_interval_days: number; + max_overdue_days: number; + description: string; + overdue_days: number[]; +} + +// Frequencies List Response +export interface FrequenciesListResponse { + data: FrequencyItem[]; + total: number; + overdue_policy: OverduePolicy; +} + export interface CreateLookupRequest { name: string; display_order?: number; @@ -517,7 +540,27 @@ const createLookupApi = (endpoint: string) => ({ export const lookupsApi = { categories: createLookupApi('categories'), priorities: createLookupApi('priorities'), - frequencies: createLookupApi('frequencies'), + frequencies: { + list: async (): Promise => { + const response = await api.get('/lookups/frequencies'); + return response.data.data; + }, + listWithPolicy: async (): Promise => { + const response = await api.get('/lookups/frequencies'); + return response.data; + }, + create: async (data: CreateLookupRequest): Promise => { + const response = await api.post('/lookups/frequencies', data); + return response.data; + }, + update: async (id: number, data: UpdateLookupRequest): Promise => { + const response = await api.put(`/lookups/frequencies/${id}`, data); + return response.data; + }, + delete: async (id: number): Promise => { + await api.delete(`/lookups/frequencies/${id}`); + }, + }, residenceTypes: createLookupApi('residence-types'), specialties: createLookupApi('specialties'), }; diff --git a/internal/admin/handlers/lookup_handler.go b/internal/admin/handlers/lookup_handler.go index 14e38f7..072fe26 100644 --- a/internal/admin/handlers/lookup_handler.go +++ b/internal/admin/handlers/lookup_handler.go @@ -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{} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 4d3210f..3b57e93 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -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") { diff --git a/internal/models/reminder_log.go b/internal/models/reminder_log.go new file mode 100644 index 0000000..4930d91 --- /dev/null +++ b/internal/models/reminder_log.go @@ -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" +} diff --git a/internal/notifications/reminder_config.go b/internal/notifications/reminder_config.go new file mode 100644 index 0000000..855eb25 --- /dev/null +++ b/internal/notifications/reminder_config.go @@ -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} diff --git a/internal/notifications/reminder_schedule.go b/internal/notifications/reminder_schedule.go new file mode 100644 index 0000000..ab8f24b --- /dev/null +++ b/internal/notifications/reminder_schedule.go @@ -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" +} diff --git a/internal/notifications/reminder_schedule_test.go b/internal/notifications/reminder_schedule_test.go new file mode 100644 index 0000000..5bf524d --- /dev/null +++ b/internal/notifications/reminder_schedule_test.go @@ -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) + } +} diff --git a/internal/repositories/reminder_repo.go b/internal/repositories/reminder_repo.go new file mode 100644 index 0000000..1fbfd4f --- /dev/null +++ b/internal/repositories/reminder_repo.go @@ -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 +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 97a47ae..0d6b0b1 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -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 diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 57cb2d2..8f31fbe 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -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 +} diff --git a/migrations/010_add_task_reminder_log.down.sql b/migrations/010_add_task_reminder_log.down.sql new file mode 100644 index 0000000..f31b796 --- /dev/null +++ b/migrations/010_add_task_reminder_log.down.sql @@ -0,0 +1,5 @@ +-- Rollback: Smart Notification Reminder System + +DROP INDEX IF EXISTS idx_reminderlog_sent_at; +DROP INDEX IF EXISTS idx_reminderlog_task_user_date; +DROP TABLE IF EXISTS task_reminderlog; diff --git a/migrations/010_add_task_reminder_log.up.sql b/migrations/010_add_task_reminder_log.up.sql new file mode 100644 index 0000000..997a2fa --- /dev/null +++ b/migrations/010_add_task_reminder_log.up.sql @@ -0,0 +1,24 @@ +-- Smart Notification Reminder System +-- Tracks which reminders have been sent to prevent duplicates + +CREATE TABLE task_reminderlog ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES task_task(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + due_date DATE NOT NULL, -- Which occurrence this is for + reminder_stage VARCHAR(20) NOT NULL, -- e.g., "reminder_30d", "reminder_7d", "day_of", "overdue_1", "overdue_4" + sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + notification_id INTEGER REFERENCES notifications_notification(id) ON DELETE SET NULL, + + -- Prevent duplicate reminders for same task/user/date/stage + UNIQUE(task_id, user_id, due_date, reminder_stage) +); + +-- Index for quick lookup when checking if reminder was already sent +CREATE INDEX idx_reminderlog_task_user_date ON task_reminderlog(task_id, user_id, due_date); + +-- Index for cleanup job (delete old logs) +CREATE INDEX idx_reminderlog_sent_at ON task_reminderlog(sent_at); + +COMMENT ON TABLE task_reminderlog IS 'Tracks sent task reminders to prevent duplicate notifications'; +COMMENT ON COLUMN task_reminderlog.reminder_stage IS 'Stage of reminder: reminder_30d, reminder_14d, reminder_7d, reminder_3d, reminder_1d, day_of, overdue_N';