'use client'; import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { listRecentTasks, updateTaskNotificationState } from '@/lib/api'; import type { Task, TaskStatus } from '@/lib/types'; const ACTIVE_STATUSES: TaskStatus[] = ['queued', 'running']; const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed']; function isTerminalTask(task: Task) { return TERMINAL_STATUSES.includes(task.status); } function taskSignature(task: Task) { return `${task.status}|${task.stage}|${task.stage_detail ?? ''}|${task.error ?? ''}`; } function taskTitle(task: Task) { switch (task.task_type) { case 'sync_filings': return 'Filing sync'; case 'refresh_prices': return 'Price refresh'; case 'analyze_filing': return 'Filing analysis'; case 'portfolio_insights': return 'Portfolio insight'; default: return 'Task'; } } function taskDescription(task: Task) { if (task.error && task.status === 'failed') { return task.error; } if (task.stage_detail) { return task.stage_detail; } switch (task.status) { case 'queued': return 'Queued and waiting for execution.'; case 'running': return 'Running in workflow engine.'; case 'completed': return 'Task finished successfully.'; case 'failed': return 'Task failed.'; default: return 'Task status changed.'; } } function shouldNotifyTask(task: Task) { return !task.notification_silenced_at; } function isUnread(task: Task) { return task.notification_read_at === null; } type UseTaskNotificationsCenterResult = { activeTasks: Task[]; finishedTasks: Task[]; unreadCount: number; isLoading: boolean; awaitingReviewTasks: Task[]; visibleFinishedTasks: Task[]; showReadFinished: boolean; setShowReadFinished: (value: boolean) => void; isPopoverOpen: boolean; setIsPopoverOpen: (value: boolean) => void; detailTaskId: string | null; setDetailTaskId: (value: string | null) => void; isDetailOpen: boolean; setIsDetailOpen: (value: boolean) => void; openTaskDetails: (taskId: string) => void; markTaskRead: (taskId: string, read?: boolean) => Promise; silenceTask: (taskId: string, silenced?: boolean) => Promise; refreshTasks: () => Promise; }; export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult { const queryClient = useQueryClient(); const [activeTasks, setActiveTasks] = useState([]); const [finishedTasks, setFinishedTasks] = useState([]); const [showReadFinished, setShowReadFinished] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [hasLoadedActive, setHasLoadedActive] = useState(false); const [hasLoadedFinished, setHasLoadedFinished] = useState(false); const [detailTaskId, setDetailTaskId] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); const activeLoadedRef = useRef(false); const finishedLoadedRef = useRef(false); const stateSignaturesRef = useRef(new Map()); const invalidatedTerminalRef = useRef(new Set()); const activeSnapshotRef = useRef([]); const finishedSnapshotRef = useRef([]); const [isDocumentVisible, setIsDocumentVisible] = useState(() => { if (typeof document === 'undefined') { return true; } return document.visibilityState === 'visible'; }); const applyTaskLocally = useCallback((task: Task) => { setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry))); setFinishedTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry))); }, []); const invalidateForTerminalTask = useCallback((task: Task) => { const key = `${task.id}:${task.status}`; if (invalidatedTerminalRef.current.has(key)) { return; } invalidatedTerminalRef.current.add(key); void queryClient.invalidateQueries({ queryKey: ['tasks'] }); switch (task.task_type) { case 'sync_filings': { void queryClient.invalidateQueries({ queryKey: ['filings'] }); void queryClient.invalidateQueries({ queryKey: ['analysis'] }); void queryClient.invalidateQueries({ queryKey: ['financials-v3'] }); break; } case 'analyze_filing': { void queryClient.invalidateQueries({ queryKey: ['filings'] }); void queryClient.invalidateQueries({ queryKey: ['report'] }); void queryClient.invalidateQueries({ queryKey: ['analysis'] }); break; } case 'refresh_prices': { void queryClient.invalidateQueries({ queryKey: ['portfolio', 'holdings'] }); void queryClient.invalidateQueries({ queryKey: ['portfolio', 'summary'] }); break; } case 'portfolio_insights': { void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] }); break; } default: break; } }, [queryClient]); const openTaskDetails = useCallback((taskId: string) => { setDetailTaskId(taskId); setIsDetailOpen(true); setIsPopoverOpen(false); }, []); const silenceTask = useCallback(async (taskId: string, silenced = true) => { try { const { task } = await updateTaskNotificationState(taskId, { silenced }); applyTaskLocally(task); toast.dismiss(taskId); } catch { toast.error('Unable to update notification state'); } }, [applyTaskLocally]); const markTaskRead = useCallback(async (taskId: string, read = true) => { try { const { task } = await updateTaskNotificationState(taskId, { read }); applyTaskLocally(task); if (read) { toast.dismiss(taskId); } } catch { toast.error('Unable to update notification state'); } }, [applyTaskLocally]); const emitTaskToast = useCallback((task: Task) => { if (!shouldNotifyTask(task)) { toast.dismiss(task.id); return; } if (task.status === 'queued' || task.status === 'running') { toast(taskTitle(task), { id: task.id, duration: Number.POSITIVE_INFINITY, description: taskDescription(task), action: { label: 'Open details', onClick: () => openTaskDetails(task.id) }, cancel: { label: 'Silence', onClick: () => { void silenceTask(task.id, true); } } }); return; } const toastBuilder = task.status === 'completed' ? toast.success : toast.error; toastBuilder(taskTitle(task), { id: task.id, duration: 10_000, description: taskDescription(task), action: { label: 'Open details', onClick: () => openTaskDetails(task.id) }, cancel: { label: 'Mark read', onClick: () => { void markTaskRead(task.id, true); } } }); }, [markTaskRead, openTaskDetails, silenceTask]); const processSnapshots = useCallback(() => { const active = activeSnapshotRef.current; const finished = finishedSnapshotRef.current; const all = [...active, ...finished]; if (!activeLoadedRef.current || !finishedLoadedRef.current) { return; } if (stateSignaturesRef.current.size === 0) { for (const task of all) { stateSignaturesRef.current.set(task.id, taskSignature(task)); } return; } for (const task of all) { const signature = taskSignature(task); const previousSignature = stateSignaturesRef.current.get(task.id); const wasKnown = previousSignature !== undefined; if (!wasKnown || previousSignature !== signature) { emitTaskToast(task); if (isTerminalTask(task)) { invalidateForTerminalTask(task); } } stateSignaturesRef.current.set(task.id, signature); } const currentIds = new Set(all.map((task) => task.id)); for (const knownId of [...stateSignaturesRef.current.keys()]) { if (!currentIds.has(knownId)) { toast.dismiss(knownId); } } }, [emitTaskToast, invalidateForTerminalTask]); const refreshTasks = useCallback(async () => { try { const [activeRes, finishedRes] = await Promise.all([ listRecentTasks({ limit: 80, statuses: ACTIVE_STATUSES }), listRecentTasks({ limit: 120, statuses: TERMINAL_STATUSES }) ]); activeSnapshotRef.current = activeRes.tasks; finishedSnapshotRef.current = finishedRes.tasks; activeLoadedRef.current = true; finishedLoadedRef.current = true; setHasLoadedActive(true); setHasLoadedFinished(true); setActiveTasks(activeRes.tasks); setFinishedTasks(finishedRes.tasks); processSnapshots(); } catch { // ignore transient polling failures } }, [processSnapshots]); useEffect(() => { if (typeof document === 'undefined') { return; } const onVisibilityChange = () => { setIsDocumentVisible(document.visibilityState === 'visible'); }; document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, []); useEffect(() => { let cancelled = false; let activeTimer: ReturnType | null = null; let terminalTimer: ReturnType | null = null; let stableTerminalPolls = 0; let previousTerminalSignature = ''; const nextActiveDelay = () => { if (!isDocumentVisible) { return 30_000; } const hasActiveTasks = activeSnapshotRef.current.length > 0; if (isPopoverOpen || isDetailOpen || hasActiveTasks) { return 2_000; } return 12_000; }; const nextTerminalDelay = () => { if (!isDocumentVisible) { return 60_000; } if (isPopoverOpen || isDetailOpen) { return 4_000; } if (finishedSnapshotRef.current.some((task) => isUnread(task))) { return 15_000; } return stableTerminalPolls >= 2 ? 45_000 : 20_000; }; const runActiveLoop = async () => { if (cancelled) { return; } try { const response = await listRecentTasks({ limit: 80, statuses: ACTIVE_STATUSES }); if (cancelled) { return; } activeSnapshotRef.current = response.tasks; activeLoadedRef.current = true; setHasLoadedActive(true); setActiveTasks(response.tasks); processSnapshots(); } catch { // ignore transient polling failures } activeTimer = setTimeout(runActiveLoop, nextActiveDelay()); }; const runTerminalLoop = async () => { if (cancelled) { return; } try { const response = await listRecentTasks({ limit: 120, statuses: TERMINAL_STATUSES }); if (cancelled) { return; } finishedSnapshotRef.current = response.tasks; finishedLoadedRef.current = true; setHasLoadedFinished(true); setFinishedTasks(response.tasks); processSnapshots(); const signature = response.tasks.map((task) => taskSignature(task)).join('||'); if (signature === previousTerminalSignature) { stableTerminalPolls += 1; } else { stableTerminalPolls = 0; previousTerminalSignature = signature; } } catch { // ignore transient polling failures } terminalTimer = setTimeout(runTerminalLoop, nextTerminalDelay()); }; void runActiveLoop(); void runTerminalLoop(); return () => { cancelled = true; if (activeTimer) { clearTimeout(activeTimer); } if (terminalTimer) { clearTimeout(terminalTimer); } }; }, [isDetailOpen, isDocumentVisible, isPopoverOpen, processSnapshots]); const normalizedActiveTasks = useMemo(() => { return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status)); }, [activeTasks]); const normalizedFinishedTasks = useMemo(() => { return finishedTasks.filter((task) => TERMINAL_STATUSES.includes(task.status)); }, [finishedTasks]); const awaitingReviewTasks = useMemo(() => { return normalizedFinishedTasks.filter((task) => isUnread(task)); }, [normalizedFinishedTasks]); const visibleFinishedTasks = useMemo(() => { if (showReadFinished) { return normalizedFinishedTasks; } return awaitingReviewTasks; }, [awaitingReviewTasks, normalizedFinishedTasks, showReadFinished]); const unreadCount = useMemo(() => { const unreadTerminal = normalizedFinishedTasks.filter((task) => isUnread(task)).length; const unreadActive = normalizedActiveTasks.filter((task) => isUnread(task) && !task.notification_silenced_at).length; return unreadTerminal + unreadActive; }, [normalizedActiveTasks, normalizedFinishedTasks]); const isLoading = !hasLoadedActive || !hasLoadedFinished; return { activeTasks: normalizedActiveTasks, finishedTasks: normalizedFinishedTasks, unreadCount, isLoading, awaitingReviewTasks, visibleFinishedTasks, showReadFinished, setShowReadFinished, isPopoverOpen, setIsPopoverOpen, detailTaskId, setDetailTaskId, isDetailOpen, setIsDetailOpen, openTaskDetails, markTaskRead, silenceTask, refreshTasks }; }