From 8aa662c096dcb1f2f5b8c8e6b16e338ab4e20d44 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 24 Feb 2026 04:01:50 +0800 Subject: [PATCH] Consolidate toast effects, extract useConfirmAction, extend index - W3: Merge route-change and new-alert effects into single unified effect - W6: Migration 018 extends due_lookup index with snoozed_until column - S1: Extract useConfirmAction hook from TodoItem/ReminderItem - S7: Update summary toast count on dismiss/snooze instead of dismissing Co-Authored-By: Claude Opus 4.6 --- .../versions/018_extend_due_lookup_index.py | 33 ++++++++++++++ .../src/components/reminders/ReminderItem.tsx | 18 ++------ frontend/src/components/todos/TodoItem.tsx | 18 ++------ frontend/src/hooks/useAlerts.tsx | 43 ++++++++++++------- frontend/src/hooks/useConfirmAction.ts | 26 +++++++++++ 5 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 backend/alembic/versions/018_extend_due_lookup_index.py create mode 100644 frontend/src/hooks/useConfirmAction.ts diff --git a/backend/alembic/versions/018_extend_due_lookup_index.py b/backend/alembic/versions/018_extend_due_lookup_index.py new file mode 100644 index 0000000..4a4f98d --- /dev/null +++ b/backend/alembic/versions/018_extend_due_lookup_index.py @@ -0,0 +1,33 @@ +"""Extend due lookup index to include snoozed_until + +Revision ID: 018 +Revises: 017 +Create Date: 2026-02-24 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "018" +down_revision = "017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("ix_reminders_due_lookup", table_name="reminders") + op.create_index( + "ix_reminders_due_lookup", + "reminders", + ["is_active", "is_dismissed", "remind_at", "snoozed_until"], + ) + + +def downgrade() -> None: + op.drop_index("ix_reminders_due_lookup", table_name="reminders") + op.create_index( + "ix_reminders_due_lookup", + "reminders", + ["is_active", "is_dismissed", "remind_at"], + ) diff --git a/frontend/src/components/reminders/ReminderItem.tsx b/frontend/src/components/reminders/ReminderItem.tsx index 69654e9..bb2b990 100644 --- a/frontend/src/components/reminders/ReminderItem.tsx +++ b/frontend/src/components/reminders/ReminderItem.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Bell, BellOff, Trash2, Pencil } from 'lucide-react'; @@ -7,6 +7,7 @@ import api from '@/lib/api'; import type { Reminder } from '@/types'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; interface ReminderItemProps { reminder: Reminder; @@ -23,9 +24,6 @@ const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const; export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) { const queryClient = useQueryClient(); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const deleteTimerRef = useRef>(); - useEffect(() => () => clearTimeout(deleteTimerRef.current), []); const remindDate = reminder.remind_at ? parseISO(reminder.remind_at) : null; const isOverdue = !reminder.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate); @@ -69,16 +67,8 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) { }, }); - const handleDelete = () => { - if (!confirmingDelete) { - setConfirmingDelete(true); - deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000); - return; - } - clearTimeout(deleteTimerRef.current); - deleteMutation.mutate(); - setConfirmingDelete(false); - }; + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); return (
>(); - useEffect(() => () => clearTimeout(deleteTimerRef.current), []); const toggleMutation = useMutation({ mutationFn: async () => { @@ -75,16 +73,8 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) { }, }); - const handleDelete = () => { - if (!confirmingDelete) { - setConfirmingDelete(true); - deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000); - return; - } - clearTimeout(deleteTimerRef.current); - deleteMutation.mutate(); - setConfirmingDelete(false); - }; + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); const dueDate = todo.due_date ? parseISO(todo.due_date) : null; const isDueToday = dueDate ? isToday(dueDate) : false; diff --git a/frontend/src/hooks/useAlerts.tsx b/frontend/src/hooks/useAlerts.tsx index 93f69ba..cd971fd 100644 --- a/frontend/src/hooks/useAlerts.tsx +++ b/frontend/src/hooks/useAlerts.tsx @@ -83,19 +83,33 @@ export function AlertsProvider({ children }: { children: ReactNode }) { }, }); + const updateSummaryToast = useCallback(() => { + // Count alerts that are still fired but don't have individual toasts (overflow) + const totalFired = firedRef.current.size; + const overflow = totalFired - MAX_TOASTS; + if (overflow > 0) { + toast.custom( + (t) => renderSummaryToast(t, overflow), + { id: 'reminder-summary', duration: Infinity } + ); + } else { + toast.dismiss('reminder-summary'); + } + }, []); + const handleDismiss = useCallback((id: number) => { toast.dismiss(`reminder-${id}`); - toast.dismiss('reminder-summary'); firedRef.current.delete(id); + updateSummaryToast(); dismissMutation.mutate(id); - }, [dismissMutation]); + }, [dismissMutation, updateSummaryToast]); const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => { toast.dismiss(`reminder-${id}`); - toast.dismiss('reminder-summary'); firedRef.current.delete(id); + updateSummaryToast(); snoozeMutation.mutate({ id, minutes }); - }, [snoozeMutation]); + }, [snoozeMutation, updateSummaryToast]); // Store latest callbacks in refs so toast closures always call current versions const dismissRef = useRef(handleDismiss); @@ -172,29 +186,28 @@ export function AlertsProvider({ children }: { children: ReactNode }) { } } - // Handle route changes + // Unified toast management — single effect handles both route changes and new alerts useEffect(() => { const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; const nowOnDashboard = isDashboard; prevPathnameRef.current = location.pathname; if (nowOnDashboard) { - // Moving TO dashboard — dismiss all toasts, banner takes over + // On dashboard — dismiss all toasts, banner takes over alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); toast.dismiss('reminder-summary'); firedRef.current.clear(); - } else if (wasOnDashboard && !nowOnDashboard) { - // Moving AWAY from dashboard — fire toasts for current alerts - firedRef.current.clear(); - fireToasts(alerts); + return; } - }, [location.pathname, isDashboard, alerts]); - // Fire toasts for new alerts on non-dashboard pages - useEffect(() => { - if (isDashboard) return; + // Transitioning away from dashboard — reset fired set so all current alerts get toasts + if (wasOnDashboard && !nowOnDashboard) { + firedRef.current.clear(); + } + + // On non-dashboard page — fire toasts for any unfired alerts fireToasts(alerts); - }, [alerts, isDashboard]); + }, [location.pathname, isDashboard, alerts]); return ( diff --git a/frontend/src/hooks/useConfirmAction.ts b/frontend/src/hooks/useConfirmAction.ts new file mode 100644 index 0000000..e5363f0 --- /dev/null +++ b/frontend/src/hooks/useConfirmAction.ts @@ -0,0 +1,26 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +/** + * Two-click confirmation pattern: first click shows "Sure?", second executes. + * Auto-resets after `timeoutMs` if the user doesn't confirm. + * Cleans up the timer on unmount. + */ +export function useConfirmAction(action: () => void, timeoutMs = 4000) { + const [confirming, setConfirming] = useState(false); + const timerRef = useRef>(); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleClick = useCallback(() => { + if (!confirming) { + setConfirming(true); + timerRef.current = setTimeout(() => setConfirming(false), timeoutMs); + return; + } + clearTimeout(timerRef.current); + action(); + setConfirming(false); + }, [confirming, action, timeoutMs]); + + return { confirming, handleClick }; +}