feat: migrate task jobs to workflow notifications + timeline

This commit is contained in:
2026-03-02 14:29:31 -05:00
parent 36c4ed2ee2
commit d81a681905
33 changed files with 2437 additions and 292 deletions

View 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
};
}