Improve job status notifications

This commit is contained in:
2026-03-09 18:53:41 -04:00
parent 1a18ac825d
commit 12a9741eca
22 changed files with 2243 additions and 302 deletions

View File

@@ -1,6 +1,7 @@
'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 {
@@ -17,45 +18,46 @@ function isTerminalTask(task: Task) {
}
function taskSignature(task: Task) {
return `${task.status}|${task.stage}|${task.stage_detail ?? ''}|${task.error ?? ''}`;
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 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 taskProgressLabel(task: Task) {
const progress = task.notification.progress;
if (!progress) {
return null;
}
return `${progress.current}/${progress.total} ${progress.unit}`;
}
function taskDescription(task: Task) {
if (task.error && task.status === 'failed') {
return task.error;
}
const lines = [
task.notification.statusLine,
task.notification.detailLine,
taskProgressLabel(task)
].filter((value): value is string => Boolean(value));
if (task.stage_detail) {
return task.stage_detail;
}
return lines.join(' • ');
}
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 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) {
@@ -82,12 +84,14 @@ type UseTaskNotificationsCenterResult = {
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[]>([]);
@@ -159,6 +163,22 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
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 });
@@ -207,14 +227,24 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
}
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: taskDescription(task),
description: terminalToastDescription(task),
action: {
label: 'Open details',
onClick: () => openTaskDetails(task.id)
label: primaryAction?.label ?? 'Open details',
onClick: () => {
if (primaryAction) {
openTaskAction(task, primaryAction.id);
return;
}
openTaskDetails(task.id);
}
},
cancel: {
label: 'Mark read',
@@ -223,7 +253,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
}
}
});
}, [markTaskRead, openTaskDetails, silenceTask]);
}, [markTaskRead, openTaskAction, openTaskDetails, silenceTask]);
const processSnapshots = useCallback(() => {
const active = activeSnapshotRef.current;
@@ -461,6 +491,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
isDetailOpen,
setIsDetailOpen,
openTaskDetails,
openTaskAction,
markTaskRead,
silenceTask,
refreshTasks