feat: migrate task jobs to workflow notifications + timeline
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
portfolioSummaryQueryOptions,
|
||||
recentTasksQueryOptions,
|
||||
taskQueryOptions,
|
||||
taskTimelineQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
|
||||
@@ -69,6 +70,13 @@ export function useTaskQuery(taskId: string, enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskTimelineQuery(taskId: string, enabled = true) {
|
||||
return useQuery({
|
||||
...taskTimelineQueryOptions(taskId),
|
||||
enabled: enabled && taskId.length > 0
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentTasksQuery(limit = 20, enabled = true) {
|
||||
return useQuery({
|
||||
...recentTasksQueryOptions(limit),
|
||||
|
||||
405
hooks/use-task-notifications-center.ts
Normal file
405
hooks/use-task-notifications-center.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
'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;
|
||||
awaitingReviewTasks: Task[];
|
||||
visibleFinishedTasks: Task[];
|
||||
showReadFinished: boolean;
|
||||
setShowReadFinished: (value: boolean) => void;
|
||||
isPopoverOpen: boolean;
|
||||
setIsPopoverOpen: (value: boolean) => void;
|
||||
isDrawerOpen: boolean;
|
||||
setIsDrawerOpen: (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 [isDrawerOpen, setIsDrawerOpen] = 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-v2'] });
|
||||
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);
|
||||
setIsDrawerOpen(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;
|
||||
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;
|
||||
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;
|
||||
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]);
|
||||
|
||||
return {
|
||||
activeTasks: normalizedActiveTasks,
|
||||
finishedTasks: normalizedFinishedTasks,
|
||||
unreadCount,
|
||||
awaitingReviewTasks,
|
||||
visibleFinishedTasks,
|
||||
showReadFinished,
|
||||
setShowReadFinished,
|
||||
isPopoverOpen,
|
||||
setIsPopoverOpen,
|
||||
isDrawerOpen,
|
||||
setIsDrawerOpen,
|
||||
detailTaskId,
|
||||
setDetailTaskId,
|
||||
isDetailOpen,
|
||||
setIsDetailOpen,
|
||||
openTaskDetails,
|
||||
markTaskRead,
|
||||
silenceTask,
|
||||
refreshTasks
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user