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:
parent
07f76294d8
commit
22f57a8674
|
|
@ -36,6 +36,7 @@ import {
|
||||||
} from "@/lib/reports-db";
|
} from "@/lib/reports-db";
|
||||||
import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
|
import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
|
||||||
import { LoginPrompt } from "@/components/auth/login-button";
|
import { LoginPrompt } from "@/components/auth/login-button";
|
||||||
|
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
|
||||||
|
|
||||||
// Market type labels
|
// Market type labels
|
||||||
const MARKET_LABELS = {
|
const MARKET_LABELS = {
|
||||||
|
|
@ -249,6 +250,9 @@ export default function HistoryPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Task Recovery Notice */}
|
||||||
|
<PendingTaskRecovery />
|
||||||
|
|
||||||
{/* Market Type Tabs */}
|
{/* Market Type Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,11 @@ export function useAnalysis() {
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|
||||||
|
// Clear pending task since it's completed
|
||||||
|
const { clearPendingTask } = await import('@/lib/pending-task');
|
||||||
|
clearPendingTask();
|
||||||
|
|
||||||
// Stop polling
|
// Stop polling
|
||||||
if (pollingIntervalRef.current) {
|
if (pollingIntervalRef.current) {
|
||||||
clearInterval(pollingIntervalRef.current);
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
|
@ -60,6 +65,11 @@ export function useAnalysis() {
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|
||||||
|
// Clear pending task since it failed
|
||||||
|
const { clearPendingTask } = await import('@/lib/pending-task');
|
||||||
|
clearPendingTask();
|
||||||
|
|
||||||
// Stop polling
|
// Stop polling
|
||||||
if (pollingIntervalRef.current) {
|
if (pollingIntervalRef.current) {
|
||||||
clearInterval(pollingIntervalRef.current);
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
|
@ -114,6 +124,16 @@ export function useAnalysis() {
|
||||||
setTaskId(taskResponse.task_id);
|
setTaskId(taskResponse.task_id);
|
||||||
setProgress("Analysis started, waiting for results...");
|
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
|
// Start polling for status
|
||||||
startPolling(taskResponse.task_id);
|
startPolling(taskResponse.task_id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue