Files
Neon-Desk/hooks/use-task-notifications-center.ts

500 lines
14 KiB
TypeScript

'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 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 JSON.stringify({
status: task.status,
stage: task.stage,
stageDetail: task.stage_detail,
stageContext: task.stage_context,
error: task.error,
result: isTerminalTask(task) ? task.result : null
});
}
function taskProgressLabel(task: Task) {
const progress = task.notification.progress;
if (!progress) {
return null;
}
return `${progress.current}/${progress.total} ${progress.unit}`;
}
function taskDescription(task: Task) {
const lines = [
task.notification.statusLine,
task.notification.detailLine,
taskProgressLabel(task)
].filter((value): value is string => Boolean(value));
return lines.join(' • ');
}
function taskTitle(task: Task) {
return task.notification.title;
}
function terminalToastDescription(task: Task) {
const topStat = task.notification.stats[0];
return [
task.notification.statusLine,
topStat ? `${topStat.label}: ${topStat.value}` : null,
task.notification.detailLine
].filter((value): value is string => Boolean(value)).join(' • ');
}
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;
openTaskAction: (task: Task, actionId?: string | null) => void;
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
refreshTasks: () => Promise<void>;
};
export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
const router = useRouter();
const queryClient = useQueryClient();
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
const [showReadFinished, setShowReadFinished] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [hasLoadedActive, setHasLoadedActive] = useState(false);
const [hasLoadedFinished, setHasLoadedFinished] = useState(false);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const activeLoadedRef = useRef(false);
const finishedLoadedRef = useRef(false);
const stateSignaturesRef = useRef(new Map<string, string>());
const invalidatedTerminalRef = useRef(new Set<string>());
const activeSnapshotRef = useRef<Task[]>([]);
const finishedSnapshotRef = useRef<Task[]>([]);
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 openTaskAction = useCallback((task: Task, actionId?: string | null) => {
const action = actionId
? task.notification.actions.find((entry) => entry.id === actionId)
: task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
?? null;
if (!action || action.id === 'open_details' || !action.href) {
openTaskDetails(task.id);
return;
}
setIsPopoverOpen(false);
router.push(action.href);
}, [openTaskDetails, router]);
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;
const primaryAction = task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
?? null;
toastBuilder(taskTitle(task), {
id: task.id,
duration: 10_000,
description: terminalToastDescription(task),
action: {
label: primaryAction?.label ?? 'Open details',
onClick: () => {
if (primaryAction) {
openTaskAction(task, primaryAction.id);
return;
}
openTaskDetails(task.id);
}
},
cancel: {
label: 'Mark read',
onClick: () => {
void markTaskRead(task.id, true);
}
}
});
}, [markTaskRead, openTaskAction, 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<typeof setTimeout> | null = null;
let terminalTimer: ReturnType<typeof setTimeout> | 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,
openTaskAction,
markTaskRead,
silenceTask,
refreshTasks
};
}