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.
This commit is contained in:
MarkLo 2025-12-14 03:55:12 +08:00
parent 07f76294d8
commit 22f57a8674
4 changed files with 288 additions and 0 deletions

View File

@ -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() {
</p>
</div>
{/* Pending Task Recovery Notice */}
<PendingTaskRecovery />
{/* Market Type Tabs */}
<Tabs
value={activeTab}

View File

@ -0,0 +1,197 @@
/**
* Pending Task Recovery Component
* Checks for pending tasks on page load and allows user to recover them
*/
"use client";
import { useState, useEffect, useCallback } from "react";
import { RefreshCw, Loader2, CheckCircle, XCircle, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import { getPendingTask, clearPendingTask, isPendingTaskValid, type PendingTask } from "@/lib/pending-task";
import { saveReport, checkDuplicateReport } from "@/lib/reports-db";
import { saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
import { useAuth } from "@/contexts/auth-context";
export function PendingTaskRecovery() {
const [pendingTask, setPendingTask] = useState<PendingTask | null>(null);
const [status, setStatus] = useState<'checking' | 'found' | 'recovering' | 'success' | 'failed' | 'not_found'>('checking');
const [message, setMessage] = useState<string>("");
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 (
<Card className={`mb-4 border-2 ${bgColor}`}>
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<Icon className={`h-5 w-5 mt-0.5 ${iconColor} ${status === 'recovering' ? 'animate-spin' : ''}`} />
<div className="flex-1">
<p className="font-semibold">
{status === 'found' && "發現未完成的分析任務"}
{status === 'recovering' && "正在恢復分析結果..."}
{status === 'success' && "報告恢復成功!"}
{status === 'failed' && "恢復失敗"}
</p>
{status === 'found' && pendingTask && (
<div className="mt-2 space-y-2">
<p className="text-sm text-muted-foreground">
<span className="font-bold">{pendingTask.ticker}</span>
( {new Date(pendingTask.startedAt).toLocaleString('zh-TW')})
</p>
<div className="flex gap-2">
<Button size="sm" onClick={handleRecover} className="gap-1">
<RefreshCw className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={handleDismiss}>
</Button>
</div>
</div>
)}
{status === 'recovering' && (
<p className="text-sm text-muted-foreground mt-1">{message}</p>
)}
{(status === 'success' || status === 'failed') && (
<div className="mt-2 space-y-2">
<p className="text-sm text-muted-foreground">{message}</p>
<Button size="sm" variant="outline" onClick={handleDismiss}>
</Button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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);

View File

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