410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
'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<void>;
|
|
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
|
refreshTasks: () => Promise<void>;
|
|
};
|
|
|
|
export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|
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 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(() => {
|
|
let cancelled = false;
|
|
let activeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let terminalTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
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, 2_000);
|
|
};
|
|
|
|
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();
|
|
} catch {
|
|
// ignore transient polling failures
|
|
}
|
|
|
|
terminalTimer = setTimeout(runTerminalLoop, 4_000);
|
|
};
|
|
|
|
void runActiveLoop();
|
|
void runTerminalLoop();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (activeTimer) {
|
|
clearTimeout(activeTimer);
|
|
}
|
|
if (terminalTimer) {
|
|
clearTimeout(terminalTimer);
|
|
}
|
|
};
|
|
}, [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
|
|
};
|
|
}
|