'use client'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { listRecentTasks, updateTaskNotificationState } from '@/lib/api'; import { buildNotificationEntries, EMPTY_FILING_SYNC_BATCH_STATE, isFilingSyncEntry, notificationEntrySignature, type FilingSyncBatchState } from '@/lib/task-notification-entries'; import type { Task, TaskNotificationEntry, 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 isTerminalEntry(entry: TaskNotificationEntry) { return TERMINAL_STATUSES.includes(entry.status); } function shouldNotifyEntry(entry: TaskNotificationEntry) { return entry.notificationSilencedAt === null; } function isUnreadEntry(entry: TaskNotificationEntry) { return entry.notificationReadAt === null; } function sortTasksByUpdated(tasks: Task[]) { return [...tasks].sort((left, right) => ( new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() )); } function latestTask(tasks: Task[]) { return sortTasksByUpdated(tasks)[0] ?? null; } function taskIdsMatch(left: string[], right: string[]) { if (left.length !== right.length) { return false; } return left.every((value, index) => value === right[index]); } function sameBatchState(left: FilingSyncBatchState, right: FilingSyncBatchState) { return ( left.active === right.active && left.latestTaskId === right.latestTaskId && left.startedAt === right.startedAt && left.finishedAt === right.finishedAt && left.terminalVisible === right.terminalVisible && taskIdsMatch(left.taskIds, right.taskIds) ); } function deriveFilingSyncBatch( current: FilingSyncBatchState, activeTasks: Task[], finishedTasks: Task[] ) { const activeSyncTasks = sortTasksByUpdated( activeTasks.filter((task) => task.task_type === 'sync_filings') ); if (activeSyncTasks.length > 0) { const resetBatch = current.terminalVisible || (!current.active && current.taskIds.length === 0); const taskIds = resetBatch ? activeSyncTasks.map((task) => task.id) : [...new Set([...current.taskIds, ...activeSyncTasks.map((task) => task.id)])]; const newestTask = activeSyncTasks[0] ?? null; const oldestActiveTask = [...activeSyncTasks].sort((left, right) => ( new Date(left.created_at).getTime() - new Date(right.created_at).getTime() ))[0] ?? null; return { active: true, taskIds, latestTaskId: newestTask?.id ?? current.latestTaskId, startedAt: resetBatch ? (oldestActiveTask?.created_at ?? newestTask?.created_at ?? null) : current.startedAt, finishedAt: null, terminalVisible: false } satisfies FilingSyncBatchState; } if (current.taskIds.length > 0 && (current.active || current.terminalVisible)) { const batchTaskIds = new Set(current.taskIds); const terminalMembers = finishedTasks.filter((task) => ( task.task_type === 'sync_filings' && batchTaskIds.has(task.id) )); const newestTerminalTask = latestTask(terminalMembers); return { ...current, active: false, latestTaskId: newestTerminalTask?.id ?? current.latestTaskId, finishedAt: newestTerminalTask?.updated_at ?? current.finishedAt ?? current.startedAt, terminalVisible: true } satisfies FilingSyncBatchState; } return EMPTY_FILING_SYNC_BATCH_STATE; } function toastIdForEntry(entry: TaskNotificationEntry) { return isFilingSyncEntry(entry) ? 'toast:filing-sync' : entry.primaryTaskId; } function entryProgressLabel(entry: TaskNotificationEntry) { const progress = entry.progress; if (!progress) { return null; } return `${progress.current}/${progress.total} ${progress.unit}`; } function entryDescription(entry: TaskNotificationEntry) { return [ entry.statusLine, entry.detailLine, entryProgressLabel(entry) ].filter((value): value is string => Boolean(value)).join(' • '); } function terminalToastDescription(entry: TaskNotificationEntry) { const topStat = entry.stats[0]; return [ entry.statusLine, topStat ? `${topStat.label}: ${topStat.value}` : null, entry.detailLine ].filter((value): value is string => Boolean(value)).join(' • '); } type UseTaskNotificationsCenterResult = { activeEntries: TaskNotificationEntry[]; finishedEntries: TaskNotificationEntry[]; unreadCount: number; isLoading: boolean; awaitingReviewEntries: TaskNotificationEntry[]; visibleFinishedEntries: TaskNotificationEntry[]; 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; openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void; markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise; silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise; refreshTasks: () => Promise; }; export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult { const router = useRouter(); const queryClient = useQueryClient(); const [activeTasks, setActiveTasks] = useState([]); const [finishedTasks, setFinishedTasks] = useState([]); const [filingSyncBatch, setFilingSyncBatch] = useState(EMPTY_FILING_SYNC_BATCH_STATE); 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 filingSyncBatchRef = useRef(EMPTY_FILING_SYNC_BATCH_STATE); const silenceEntryRef = useRef<(entry: TaskNotificationEntry, silenced?: boolean) => Promise>(async () => {}); const markEntryReadRef = useRef<(entry: TaskNotificationEntry, read?: boolean) => Promise>(async () => {}); const [isDocumentVisible, setIsDocumentVisible] = useState(() => { if (typeof document === 'undefined') { return true; } return document.visibilityState === 'visible'; }); const syncBatchState = useCallback((nextBatch: FilingSyncBatchState) => { filingSyncBatchRef.current = nextBatch; setFilingSyncBatch((current) => sameBatchState(current, nextBatch) ? current : nextBatch); }, []); const mergeTasksLocally = useCallback((tasks: Task[]) => { if (tasks.length === 0) { return; } const taskMap = new Map(tasks.map((task) => [task.id, task])); const mergeList = (list: Task[]) => list.map((entry) => taskMap.get(entry.id) ?? entry); activeSnapshotRef.current = mergeList(activeSnapshotRef.current); finishedSnapshotRef.current = mergeList(finishedSnapshotRef.current); setActiveTasks((current) => mergeList(current)); setFinishedTasks((current) => mergeList(current)); }, []); 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; } case 'research_brief': { void queryClient.invalidateQueries({ queryKey: ['research'] }); void queryClient.invalidateQueries({ queryKey: ['analysis'] }); break; } default: break; } }, [queryClient]); const openTaskDetails = useCallback((taskId: string) => { setDetailTaskId(taskId); setIsDetailOpen(true); setIsPopoverOpen(false); }, []); const openTaskAction = useCallback((entry: TaskNotificationEntry, actionId?: string | null) => { const action = actionId ? entry.actions.find((candidate) => candidate.id === actionId) : entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details') ?? entry.actions.find((candidate) => candidate.id !== 'open_details') ?? null; if (!action || action.id === 'open_details' || !action.href) { openTaskDetails(entry.primaryTaskId); return; } setIsPopoverOpen(false); router.push(action.href); }, [openTaskDetails, router]); const emitEntryToast = useCallback((entry: TaskNotificationEntry) => { const toastId = toastIdForEntry(entry); if (!shouldNotifyEntry(entry)) { toast.dismiss(toastId); return; } if (entry.status === 'queued' || entry.status === 'running') { toast(entry.title, { id: toastId, duration: Number.POSITIVE_INFINITY, description: entryDescription(entry), action: { label: 'Open details', onClick: () => openTaskDetails(entry.primaryTaskId) }, cancel: { label: 'Silence', onClick: () => { void silenceEntryRef.current(entry, true); } } }); return; } const toastBuilder = entry.status === 'completed' ? toast.success : toast.error; const primaryAction = entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details') ?? entry.actions.find((candidate) => candidate.id !== 'open_details') ?? null; toastBuilder(entry.title, { id: toastId, duration: 10_000, description: terminalToastDescription(entry), action: { label: primaryAction?.label ?? 'Open details', onClick: () => { if (primaryAction) { openTaskAction(entry, primaryAction.id); return; } openTaskDetails(entry.primaryTaskId); } }, cancel: { label: 'Mark read', onClick: () => { void markEntryReadRef.current(entry, true); } } }); }, [openTaskAction, openTaskDetails]); const processSnapshots = useCallback((nextBatch = filingSyncBatchRef.current) => { if (!activeLoadedRef.current || !finishedLoadedRef.current) { return; } const entries = buildNotificationEntries({ activeTasks: activeSnapshotRef.current, finishedTasks: finishedSnapshotRef.current, filingSyncBatch: nextBatch }); if (stateSignaturesRef.current.size === 0) { for (const entry of entries) { stateSignaturesRef.current.set(entry.id, notificationEntrySignature(entry)); } return; } for (const entry of entries) { const signature = notificationEntrySignature(entry); const previousSignature = stateSignaturesRef.current.get(entry.id); const wasKnown = previousSignature !== undefined; if (!wasKnown || previousSignature !== signature) { emitEntryToast(entry); if (!isFilingSyncEntry(entry) && isTerminalEntry(entry)) { const terminalTask = [ ...activeSnapshotRef.current, ...finishedSnapshotRef.current ].find((task) => task.id === entry.primaryTaskId); if (terminalTask) { invalidateForTerminalTask(terminalTask); } } if (isFilingSyncEntry(entry) && isTerminalEntry(entry)) { for (const task of [...activeSnapshotRef.current, ...finishedSnapshotRef.current]) { if (task.task_type === 'sync_filings' && entry.taskIds.includes(task.id) && isTerminalTask(task)) { invalidateForTerminalTask(task); } } } } stateSignaturesRef.current.set(entry.id, signature); } const currentIds = new Set(entries.map((entry) => entry.id)); for (const knownId of [...stateSignaturesRef.current.keys()]) { if (!currentIds.has(knownId)) { const toastId = knownId === 'filing-sync:active' ? 'toast:filing-sync' : knownId; toast.dismiss(toastId); stateSignaturesRef.current.delete(knownId); } } }, [emitEntryToast, invalidateForTerminalTask]); const applySnapshotState = useCallback(( nextActiveTasks: Task[], nextFinishedTasks: Task[], loaded: { active?: boolean; finished?: boolean } = {} ) => { activeSnapshotRef.current = nextActiveTasks; finishedSnapshotRef.current = nextFinishedTasks; if (loaded.active) { activeLoadedRef.current = true; setHasLoadedActive(true); } if (loaded.finished) { finishedLoadedRef.current = true; setHasLoadedFinished(true); } setActiveTasks(nextActiveTasks); setFinishedTasks(nextFinishedTasks); const nextBatch = deriveFilingSyncBatch( filingSyncBatchRef.current, nextActiveTasks, nextFinishedTasks ); syncBatchState(nextBatch); processSnapshots(nextBatch); }, [processSnapshots, syncBatchState]); const updateEntryNotification = useCallback(async ( entry: TaskNotificationEntry, input: { read?: boolean; silenced?: boolean } ) => { const results = await Promise.allSettled( entry.taskIds.map((taskId) => updateTaskNotificationState(taskId, input)) ); const updatedTasks = results.flatMap((result) => ( result.status === 'fulfilled' ? [result.value.task] : [] )); if (updatedTasks.length > 0) { mergeTasksLocally(updatedTasks); } let nextBatch = deriveFilingSyncBatch( filingSyncBatchRef.current, activeSnapshotRef.current, finishedSnapshotRef.current ); if ( isFilingSyncEntry(entry) && isTerminalEntry(entry) && (input.read || input.silenced) && results.every((result) => result.status === 'fulfilled') ) { nextBatch = EMPTY_FILING_SYNC_BATCH_STATE; } syncBatchState(nextBatch); processSnapshots(nextBatch); if (results.some((result) => result.status === 'rejected')) { toast.error('Unable to update notification state'); return; } if (input.read || input.silenced) { toast.dismiss(toastIdForEntry(entry)); } }, [mergeTasksLocally, processSnapshots, syncBatchState]); const silenceEntry = useCallback(async (entry: TaskNotificationEntry, silenced = true) => { await updateEntryNotification(entry, { silenced }); }, [updateEntryNotification]); const markEntryRead = useCallback(async (entry: TaskNotificationEntry, read = true) => { await updateEntryNotification(entry, { read }); }, [updateEntryNotification]); silenceEntryRef.current = silenceEntry; markEntryReadRef.current = markEntryRead; const refreshTasks = useCallback(async () => { try { const [activeRes, finishedRes] = await Promise.all([ listRecentTasks({ limit: 80, statuses: ACTIVE_STATUSES }), listRecentTasks({ limit: 120, statuses: TERMINAL_STATUSES }) ]); applySnapshotState(activeRes.tasks, finishedRes.tasks, { active: true, finished: true }); } catch { // ignore transient polling failures } }, [applySnapshotState]); 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; } const terminalEntries = buildNotificationEntries({ activeTasks: activeSnapshotRef.current, finishedTasks: finishedSnapshotRef.current, filingSyncBatch: filingSyncBatchRef.current }).filter(isTerminalEntry); if (terminalEntries.some((entry) => isUnreadEntry(entry))) { 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; } applySnapshotState(response.tasks, finishedSnapshotRef.current, { active: true }); } 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; } applySnapshotState(activeSnapshotRef.current, response.tasks, { finished: true }); const signature = response.tasks .map((task) => notificationEntrySignature({ id: task.id, kind: 'single', status: task.status, title: task.notification.title, statusLine: task.notification.statusLine, detailLine: task.notification.detailLine, progress: task.notification.progress, stats: task.notification.stats, updatedAt: task.updated_at, primaryTaskId: task.id, taskIds: [task.id], actions: task.notification.actions, notificationReadAt: task.notification_read_at, notificationSilencedAt: task.notification_silenced_at })) .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); } }; }, [applySnapshotState, isDetailOpen, isDocumentVisible, isPopoverOpen]); const entries = useMemo(() => buildNotificationEntries({ activeTasks, finishedTasks, filingSyncBatch }), [activeTasks, filingSyncBatch, finishedTasks]); const activeEntries = useMemo(() => { return entries.filter((entry) => ACTIVE_STATUSES.includes(entry.status)); }, [entries]); const normalizedFinishedEntries = useMemo(() => { return entries.filter((entry) => TERMINAL_STATUSES.includes(entry.status)); }, [entries]); const awaitingReviewEntries = useMemo(() => { return normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)); }, [normalizedFinishedEntries]); const visibleFinishedEntries = useMemo(() => { if (showReadFinished) { return normalizedFinishedEntries; } return awaitingReviewEntries; }, [awaitingReviewEntries, normalizedFinishedEntries, showReadFinished]); const unreadCount = useMemo(() => { const unreadTerminal = normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)).length; const unreadActive = activeEntries.filter((entry) => ( isUnreadEntry(entry) && entry.notificationSilencedAt === null )).length; return unreadTerminal + unreadActive; }, [activeEntries, normalizedFinishedEntries]); const isLoading = !hasLoadedActive || !hasLoadedFinished; return { activeEntries, finishedEntries: normalizedFinishedEntries, unreadCount, isLoading, awaitingReviewEntries, visibleFinishedEntries, showReadFinished, setShowReadFinished, isPopoverOpen, setIsPopoverOpen, detailTaskId, setDetailTaskId, isDetailOpen, setIsDetailOpen, openTaskDetails, openTaskAction, markEntryRead, silenceEntry, refreshTasks }; }