From 22f57a8674b210c8f9d7c28ebe0f4d98938910a7 Mon Sep 17 00:00:00 2001 From: MarkLo Date: Sun, 14 Dec 2025 03:55:12 +0800 Subject: [PATCH] feat: recover pending analysis tasks after page close Added task recovery system that allows users to recover and save analysis results even if they accidentally close the page: 1. New pending-task.ts utility: - Saves task info to localStorage when analysis starts - Clears after task completes/fails - 24-hour expiry for old tasks 2. PendingTaskRecovery component: - Shows on history page if pending task found - Polls API for task status - Saves result to IndexedDB + cloud upon completion 3. Updated useAnalysis hook: - Tracks pending tasks in localStorage - Clears on completion/failure Now users visiting /history after closing the page during analysis will see a prompt to recover and save their completed report. --- frontend/app/history/page.tsx | 4 + frontend/components/PendingTaskRecovery.tsx | 197 ++++++++++++++++++++ frontend/hooks/useAnalysis.ts | 20 ++ frontend/lib/pending-task.ts | 67 +++++++ 4 files changed, 288 insertions(+) create mode 100644 frontend/components/PendingTaskRecovery.tsx create mode 100644 frontend/lib/pending-task.ts diff --git a/frontend/app/history/page.tsx b/frontend/app/history/page.tsx index 5623243b..2568bbd7 100644 --- a/frontend/app/history/page.tsx +++ b/frontend/app/history/page.tsx @@ -36,6 +36,7 @@ import { } from "@/lib/reports-db"; import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; import { LoginPrompt } from "@/components/auth/login-button"; +import { PendingTaskRecovery } from "@/components/PendingTaskRecovery"; // Market type labels const MARKET_LABELS = { @@ -249,6 +250,9 @@ export default function HistoryPage() {

+ {/* Pending Task Recovery Notice */} + + {/* Market Type Tabs */} (null); + const [status, setStatus] = useState<'checking' | 'found' | 'recovering' | 'success' | 'failed' | 'not_found'>('checking'); + const [message, setMessage] = useState(""); + const { isAuthenticated } = useAuth(); + + useEffect(() => { + // Check for pending tasks on mount + const task = getPendingTask(); + + if (!task) { + setStatus('not_found'); + return; + } + + if (!isPendingTaskValid(task)) { + // Task is too old, clear it + clearPendingTask(); + setStatus('not_found'); + return; + } + + setPendingTask(task); + setStatus('found'); + }, []); + + const handleRecover = useCallback(async () => { + if (!pendingTask) return; + + setStatus('recovering'); + setMessage("正在檢查任務狀態..."); + + try { + // Check task status + const taskStatus = await api.getTaskStatus(pendingTask.taskId); + + if (taskStatus.status === 'completed' && taskStatus.result) { + setMessage("分析已完成!正在儲存報告..."); + + // Check for duplicate + const duplicate = await checkDuplicateReport( + taskStatus.result.ticker, + taskStatus.result.analysis_date + ); + + if (duplicate) { + setMessage("報告已存在於歷史記錄中"); + clearPendingTask(); + setStatus('success'); + return; + } + + // Save to local IndexedDB + await saveReport( + taskStatus.result.ticker, + pendingTask.marketType, + taskStatus.result.analysis_date, + taskStatus.result, + pendingTask.taskId + ); + + // If authenticated, also save to cloud + if (isAuthenticated && isCloudSyncEnabled()) { + await saveCloudReport({ + ticker: taskStatus.result.ticker, + market_type: pendingTask.marketType, + analysis_date: taskStatus.result.analysis_date, + result: taskStatus.result, + }); + } + + clearPendingTask(); + setMessage("報告已成功儲存到歷史記錄!"); + setStatus('success'); + + } else if (taskStatus.status === 'failed') { + clearPendingTask(); + setMessage("分析任務失敗"); + setStatus('failed'); + + } else if (taskStatus.status === 'running' || taskStatus.status === 'pending') { + setMessage(`分析仍在進行中... (${taskStatus.progress || '處理中'})`); + // Poll again after a delay + setTimeout(() => { + handleRecover(); + }, 3000); + + } else { + // Unknown status, clear it + clearPendingTask(); + setMessage("無法恢復任務"); + setStatus('failed'); + } + + } catch (error: any) { + console.error("Recovery failed:", error); + + // If it's a 404, the task doesn't exist anymore + if (error?.response?.status === 404) { + clearPendingTask(); + setMessage("任務已過期或不存在"); + setStatus('failed'); + } else { + setMessage(`恢復失敗: ${error.message || '未知錯誤'}`); + setStatus('failed'); + } + } + }, [pendingTask, isAuthenticated]); + + const handleDismiss = () => { + clearPendingTask(); + setStatus('not_found'); + setPendingTask(null); + }; + + // Don't render if no pending task or already checked + if (status === 'checking' || status === 'not_found') { + return null; + } + + const bgColor = status === 'success' ? 'bg-green-500/10 border-green-500' : + status === 'failed' ? 'bg-red-500/10 border-red-500' : + 'bg-yellow-500/10 border-yellow-500'; + + const Icon = status === 'success' ? CheckCircle : + status === 'failed' ? XCircle : + status === 'recovering' ? Loader2 : AlertCircle; + + const iconColor = status === 'success' ? 'text-green-500' : + status === 'failed' ? 'text-red-500' : + 'text-yellow-500'; + + return ( + + +
+ +
+

+ {status === 'found' && "發現未完成的分析任務"} + {status === 'recovering' && "正在恢復分析結果..."} + {status === 'success' && "報告恢復成功!"} + {status === 'failed' && "恢復失敗"} +

+ + {status === 'found' && pendingTask && ( +
+

+ 發現 {pendingTask.ticker} 的分析任務 + (開始於 {new Date(pendingTask.startedAt).toLocaleString('zh-TW')}) +

+
+ + +
+
+ )} + + {status === 'recovering' && ( +

{message}

+ )} + + {(status === 'success' || status === 'failed') && ( +
+

{message}

+ +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/hooks/useAnalysis.ts b/frontend/hooks/useAnalysis.ts index ea0a2acb..0744aa41 100644 --- a/frontend/hooks/useAnalysis.ts +++ b/frontend/hooks/useAnalysis.ts @@ -37,6 +37,11 @@ export function useAnalysis() { } setLoading(false); setProgress(null); + + // Clear pending task since it's completed + const { clearPendingTask } = await import('@/lib/pending-task'); + clearPendingTask(); + // Stop polling if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); @@ -60,6 +65,11 @@ export function useAnalysis() { } setLoading(false); setProgress(null); + + // Clear pending task since it failed + const { clearPendingTask } = await import('@/lib/pending-task'); + clearPendingTask(); + // Stop polling if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); @@ -114,6 +124,16 @@ export function useAnalysis() { setTaskId(taskResponse.task_id); setProgress("Analysis started, waiting for results..."); + // Save pending task to localStorage for recovery if page closes + const { savePendingTask } = await import('@/lib/pending-task'); + savePendingTask({ + taskId: taskResponse.task_id, + ticker: request.ticker, + marketType: request.market_type || 'us', + analysisDate: request.analysis_date, + startedAt: new Date().toISOString(), + }); + // Start polling for status startPolling(taskResponse.task_id); diff --git a/frontend/lib/pending-task.ts b/frontend/lib/pending-task.ts new file mode 100644 index 00000000..b8ca7a0b --- /dev/null +++ b/frontend/lib/pending-task.ts @@ -0,0 +1,67 @@ +/** + * Pending Task Tracker + * Saves in-progress analysis tasks to localStorage so they can be recovered + * if the user accidentally closes the page before completion + */ + +const PENDING_TASK_KEY = 'tradingagents_pending_task'; + +export interface PendingTask { + taskId: string; + ticker: string; + marketType: 'us' | 'twse' | 'tpex'; + analysisDate: string; + startedAt: string; +} + +/** + * Save a pending task to localStorage + */ +export function savePendingTask(task: PendingTask): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(PENDING_TASK_KEY, JSON.stringify(task)); + console.log('📝 Saved pending task:', task.taskId); + } catch (error) { + console.error('Failed to save pending task:', error); + } +} + +/** + * Get any pending task from localStorage + */ +export function getPendingTask(): PendingTask | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem(PENDING_TASK_KEY); + if (!stored) return null; + return JSON.parse(stored) as PendingTask; + } catch (error) { + console.error('Failed to get pending task:', error); + return null; + } +} + +/** + * Clear the pending task from localStorage (after successful save or completion) + */ +export function clearPendingTask(): void { + if (typeof window === 'undefined') return; + try { + localStorage.removeItem(PENDING_TASK_KEY); + console.log('✅ Cleared pending task'); + } catch (error) { + console.error('Failed to clear pending task:', error); + } +} + +/** + * Check if a pending task is still valid (not too old) + * Tasks older than 24 hours are considered expired + */ +export function isPendingTaskValid(task: PendingTask): boolean { + const started = new Date(task.startedAt).getTime(); + const now = Date.now(); + const maxAge = 24 * 60 * 60 * 1000; // 24 hours + return now - started < maxAge; +}