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:
@@ -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 "Add Item" 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 "{deleteItem?.name}"? 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
@@ -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'),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user