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') && (
+
+ )}
+
+
+
+
+ );
+}
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;
+}