From 421682c44735670cd520a8fd7c0873e49736175b Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Tue, 19 May 2026 00:49:36 -0600 Subject: [PATCH] Pause AlertToast auto-dismiss while hovered (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each alert toast had a 5-second auto-dismiss timer that fired even while the user was reading the card. This adds pause-on-hover: the dismiss timer stops while the mouse is over a toast and restarts (full lifetime) on mouse leave. The progress bar animation pauses with it, so the visual matches the actual remaining time. All other behavior is preserved: same cyber/mono styling, same spring slide-in, same risk-color border + glow, same warning icon, same LVL X/10 readout, same title/source layout, same click-to-fly + dismiss on body click, same × dismiss button. Implementation notes: - Extract a ToastCard sub-component so each card can own its own paused state (useState can't be array-indexed in the parent). - Move the auto-dismiss timer out of useAlertToasts.ts and into ToastCard. The hook previously scheduled the dismiss itself, which meant the UI couldn't pause it — only the component knows whether the user is interacting. - Add tests covering: title/source/severity render, auto-dismiss fires at 5s, hover pauses indefinitely, mouse-leave restarts the full lifetime, × dismisses without flying, body-click flies + dismisses. This implements the genuine UX improvement that PR #234 was reaching for, without #234's broken syntax, missing-field bug, duplicate timer logic, or design regression. Refs: #234 Co-authored-by: Claude Opus 4.7 --- .../__tests__/components/AlertToast.test.tsx | 126 +++++++++++ frontend/src/components/AlertToast.tsx | 208 +++++++++++------- frontend/src/hooks/useAlertToasts.ts | 40 +--- 3 files changed, 261 insertions(+), 113 deletions(-) create mode 100644 frontend/src/__tests__/components/AlertToast.test.tsx diff --git a/frontend/src/__tests__/components/AlertToast.test.tsx b/frontend/src/__tests__/components/AlertToast.test.tsx new file mode 100644 index 0000000..d4f32ec --- /dev/null +++ b/frontend/src/__tests__/components/AlertToast.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import AlertToast from '@/components/AlertToast'; +import type { ToastItem } from '@/hooks/useAlertToasts'; + +function buildToast(partial: Partial = {}): ToastItem { + return { + id: 'toast-1', + title: 'Embassy evacuation reported', + source: 'Reuters', + risk_score: 9, + lat: 38.9, + lng: -77.0, + timestamp: Date.now(), + ...partial, + }; +} + +describe('AlertToast', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('renders the toast title, source, and severity label', () => { + const toast = buildToast(); + render( + , + ); + + expect(screen.getByText(toast.title)).toBeTruthy(); + expect(screen.getByText(toast.source)).toBeTruthy(); + // 9/10 -> CRITICAL + expect(screen.getByText(/CRITICAL/)).toBeTruthy(); + expect(screen.getByText(/LVL 9\/10/)).toBeTruthy(); + }); + + it('auto-dismisses after 5 seconds', () => { + const onDismiss = vi.fn(); + const toast = buildToast(); + render( + , + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(onDismiss).toHaveBeenCalledWith(toast.id); + }); + + it('pauses auto-dismiss while the card is hovered', () => { + const onDismiss = vi.fn(); + const toast = buildToast(); + render( + , + ); + + // Hover before the timer fires. mouseEnter must be flushed + // (state update + effect cleanup) in its own act() before we + // advance timers — otherwise the original mount-time timer is + // still active when advanceTimersByTime runs. + const card = screen.getByText(toast.title).closest('[class*="cursor-pointer"]')!; + expect(card).toBeTruthy(); + + act(() => { + fireEvent.mouseEnter(card); + }); + act(() => { + vi.advanceTimersByTime(10_000); + }); + + // Still no dismiss — timer is paused. + expect(onDismiss).not.toHaveBeenCalled(); + + // Leave: a fresh full-lifetime timer starts. + act(() => { + fireEvent.mouseLeave(card); + }); + act(() => { + vi.advanceTimersByTime(4_999); + }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onDismiss).toHaveBeenCalledWith(toast.id); + }); + + it('dismisses on × button click without calling onFlyTo', () => { + const onDismiss = vi.fn(); + const onFlyTo = vi.fn(); + const toast = buildToast(); + render( + , + ); + + fireEvent.click(screen.getByText('×')); + + expect(onDismiss).toHaveBeenCalledWith(toast.id); + expect(onFlyTo).not.toHaveBeenCalled(); + }); + + it('flies to the toast location and dismisses on body click', () => { + const onDismiss = vi.fn(); + const onFlyTo = vi.fn(); + const toast = buildToast(); + render( + , + ); + + fireEvent.click(screen.getByText(toast.title)); + + expect(onFlyTo).toHaveBeenCalledWith(toast.lat, toast.lng); + expect(onDismiss).toHaveBeenCalledWith(toast.id); + }); +}); diff --git a/frontend/src/components/AlertToast.tsx b/frontend/src/components/AlertToast.tsx index 6e516d7..0c51245 100644 --- a/frontend/src/components/AlertToast.tsx +++ b/frontend/src/components/AlertToast.tsx @@ -1,8 +1,11 @@ 'use client'; +import { useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import type { ToastItem } from '@/hooks/useAlertToasts'; +const TOAST_LIFETIME_MS = 5_000; + function getRiskColor(score: number): string { if (score >= 9) return '#ef4444'; if (score >= 7) return '#f97316'; @@ -16,6 +19,122 @@ function getRiskLabel(score: number): string { return 'ELEVATED'; } +/** + * ToastCard — renders a single toast with hover-to-pause auto-dismiss. + * + * Each card owns its own 5s dismiss timer. Hovering the card pauses the + * timer; the timer restarts (full duration) on mouse leave. All visual + * styling, the progress bar animation, the click-to-fly behavior, and + * the dismiss button match the previous inline implementation — the + * only behavioral change is the pause-on-hover. + */ +function ToastCard({ + toast, + onDismiss, + onFlyTo, +}: { + toast: ToastItem; + onDismiss: (id: string) => void; + onFlyTo?: (lat: number, lng: number) => void; +}) { + const [isPaused, setIsPaused] = useState(false); + const color = getRiskColor(toast.risk_score); + const label = getRiskLabel(toast.risk_score); + + // Per-toast auto-dismiss timer. Restarts whenever the pause flag flips + // off — so hovering resets the clock back to a full lifetime when the + // user moves the mouse away, giving them time to actually read it. + useEffect(() => { + if (isPaused) return; + + const timer = setTimeout(() => { + onDismiss(toast.id); + }, TOAST_LIFETIME_MS); + + return () => clearTimeout(timer); + }, [isPaused, toast.id, onDismiss]); + + return ( + setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onClick={() => { + if (onFlyTo && toast.lat && toast.lng) { + onFlyTo(toast.lat, toast.lng); + } + onDismiss(toast.id); + }} + > +
+ {/* Progress bar — animation pauses while the card is hovered. */} + + +
+ {/* Header */} +
+ + ⚠ {label} + + + LVL {toast.risk_score}/10 + +
+ + {/* Title */} +
+ {toast.title} +
+ + {/* Source */} +
+ {toast.source} +
+
+ + {/* Dismiss button */} + +
+
+ ); +} + export default function AlertToast({ toasts, onDismiss, @@ -28,87 +147,14 @@ export default function AlertToast({ return (
- {toasts.map((toast) => { - const color = getRiskColor(toast.risk_score); - const label = getRiskLabel(toast.risk_score); - return ( - { - if (onFlyTo && toast.lat && toast.lng) { - onFlyTo(toast.lat, toast.lng); - } - onDismiss(toast.id); - }} - > -
- {/* Progress bar */} - - -
- {/* Header */} -
- - ⚠ {label} - - - LVL {toast.risk_score}/10 - -
- - {/* Title */} -
- {toast.title} -
- - {/* Source */} -
- {toast.source} -
-
- - {/* Dismiss button */} - -
-
- ); - })} + {toasts.map((toast) => ( + + ))}
); diff --git a/frontend/src/hooks/useAlertToasts.ts b/frontend/src/hooks/useAlertToasts.ts index 2f9dc8f..79a71ac 100644 --- a/frontend/src/hooks/useAlertToasts.ts +++ b/frontend/src/hooks/useAlertToasts.ts @@ -2,7 +2,13 @@ * useAlertToasts — watches for new high-severity news items and surfaces toast notifications. * * Monitors the `news` data key for articles with risk_score >= 8. - * Maintains a seen-set to avoid duplicate toasts. Auto-dismisses after 5 seconds. + * Maintains a seen-set to avoid duplicate toasts. + * + * NOTE: auto-dismissal is owned by the `AlertToast` component (per-card + * timer with pause-on-hover) — this hook used to schedule its own + * dismiss timer, but that prevented the UI from pausing it. The hook + * now only manages the toast queue + dedup; the component decides when + * a toast goes away. */ import { useState, useEffect, useRef, useCallback } from 'react'; import { useDataKey } from './useDataStore'; @@ -20,30 +26,14 @@ export interface ToastItem { const TOAST_THRESHOLD = 8; // minimum risk_score to trigger a toast const MAX_VISIBLE = 3; -const AUTO_DISMISS_MS = 5_000; export function useAlertToasts() { const news = useDataKey('news') as NewsArticle[] | undefined; const seenKeys = useRef(new Set()); const [toasts, setToasts] = useState([]); - const timersRef = useRef>>(new Map()); - - // Auto-dismiss scheduled toasts - const scheduleDismiss = useCallback((id: string) => { - const timer = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - timersRef.current.delete(id); - }, AUTO_DISMISS_MS); - timersRef.current.set(id, timer); - }, []); const dismiss = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); - const timer = timersRef.current.get(id); - if (timer) { - clearTimeout(timer); - timersRef.current.delete(id); - } }, []); // Watch for new high-severity articles @@ -76,22 +66,8 @@ export function useAlertToasts() { const merged = [...newToasts, ...prev].slice(0, MAX_VISIBLE); return merged; }); - - // Schedule auto-dismiss for each new toast - for (const t of newToasts) { - scheduleDismiss(t.id); - } } - }, [news, scheduleDismiss]); - - // Cleanup timers on unmount - useEffect(() => { - return () => { - for (const timer of timersRef.current.values()) { - clearTimeout(timer); - } - }; - }, []); + }, [news]); return { toasts, dismiss }; }