Add smart notification reminder system with frequency-aware scheduling

Replaces one-size-fits-all "2 days before" reminders with intelligent
scheduling based on task frequency. Infrequent tasks (annual) get 30-day
advance notice while frequent tasks (weekly) only get day-of reminders.

Key features:
- Frequency-aware pre-reminders: annual (30d, 14d, 7d), quarterly (7d, 3d),
  monthly (3d), bi-weekly (1d), daily/weekly/once (day-of only)
- Overdue tapering: daily for 3 days, then every 3 days, stops after 14 days
- Reminder log table prevents duplicate notifications per due date/stage
- Admin endpoint displays notification schedules for all frequencies
- Comprehensive test suite (100 random tasks, 61 days each, 10 test functions)

New files:
- internal/notifications/reminder_config.go - Editable schedule configuration
- internal/notifications/reminder_schedule.go - Schedule lookup logic
- internal/notifications/reminder_schedule_test.go - Dynamic test suite
- internal/models/reminder_log.go - TaskReminderLog model
- internal/repositories/reminder_repo.go - Reminder log repository
- migrations/010_add_task_reminder_log.{up,down}.sql

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-19 23:03:28 -06:00
parent 7a57a902bb
commit 69206c6930
13 changed files with 1733 additions and 28 deletions
+320 -4
View File
@@ -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<number | null>(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<FrequencyItem | null>(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 <div className="flex items-center justify-center h-32 text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-4">
{/* Overdue Policy Info */}
{overduePolicy && (
<div className="p-4 bg-muted/50 rounded-lg border">
<div className="flex items-center gap-2 mb-2">
<Bell className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">Overdue Reminder Policy</span>
</div>
<p className="text-sm text-muted-foreground">{overduePolicy.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{overduePolicy.overdue_days.map((day) => (
<Badge key={day} variant="secondary" className="text-xs">
Day {day}
</Badge>
))}
</div>
</div>
)}
<div className="flex justify-end">
<Button onClick={() => setIsAdding(true)} disabled={isAdding}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Notification Schedule</TableHead>
<TableHead className="w-24">Order</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isAdding && (
<TableRow>
<TableCell>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Enter name..."
className="h-8"
autoFocus
/>
</TableCell>
<TableCell>
<span className="text-muted-foreground text-sm">Auto-assigned based on interval</span>
</TableCell>
<TableCell>
<Input
type="number"
value={newOrder}
onChange={(e) => setNewOrder(parseInt(e.target.value) || 0)}
className="h-8 w-16"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleCreate}
disabled={createMutation.isPending || !newName.trim()}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setIsAdding(false);
setNewName('');
setNewOrder(0);
}}
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
</TableCell>
</TableRow>
)}
{sortedItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{editingId === item.id ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="h-8"
/>
) : (
<div>
<div className="font-medium">{item.name}</div>
{item.days !== null && (
<div className="text-xs text-muted-foreground">
{item.days} day{item.days !== 1 ? 's' : ''} interval
</div>
)}
</div>
)}
</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Bell className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{item.notification_schedule}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Reminder days before due: {item.reminder_days.join(', ') || 'Day-of only'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>
{editingId === item.id ? (
<Input
type="number"
value={editingOrder}
onChange={(e) => setEditingOrder(parseInt(e.target.value) || 0)}
className="h-8 w-16"
/>
) : (
item.display_order
)}
</TableCell>
<TableCell>
{editingId === item.id ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSave}
disabled={updateMutation.isPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditingId(null)}
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeleteItem(item)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
{sortedItems.length === 0 && !isAdding && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No frequencies found. Click &quot;Add Item&quot; to create one.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<AlertDialog open={!!deleteItem} onOpenChange={(open) => !open && setDeleteItem(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Frequency</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteItem?.name}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteItem && deleteMutation.mutate(deleteItem.id)}
className="bg-red-600 hover:bg-red-700"
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function LookupsPage() {
return (
<div className="space-y-6">
@@ -333,7 +645,11 @@ export default function LookupsPage() {
</TabsList>
{lookupTabs.map((tab) => (
<TabsContent key={tab.key} value={tab.key} className="mt-4">
<LookupTable lookupKey={tab.key} api={tab.api} />
{tab.key === 'frequencies' ? (
<FrequenciesTable />
) : (
<LookupTable lookupKey={tab.key} api={tab.api!} />
)}
</TabsContent>
))}
</Tabs>
+44 -1
View File
@@ -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<FrequencyItem[]> => {
const response = await api.get<FrequenciesListResponse>('/lookups/frequencies');
return response.data.data;
},
listWithPolicy: async (): Promise<FrequenciesListResponse> => {
const response = await api.get<FrequenciesListResponse>('/lookups/frequencies');
return response.data;
},
create: async (data: CreateLookupRequest): Promise<FrequencyItem> => {
const response = await api.post<FrequencyItem>('/lookups/frequencies', data);
return response.data;
},
update: async (id: number, data: UpdateLookupRequest): Promise<FrequencyItem> => {
const response = await api.put<FrequencyItem>(`/lookups/frequencies/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/lookups/frequencies/${id}`);
},
},
residenceTypes: createLookupApi('residence-types'),
specialties: createLookupApi('specialties'),
};