TradingAgents/frontend/app/history/page.tsx

1190 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* History page - browse saved analysis reports
*/
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { zhTW } from "date-fns/locale";
import { useAnalysisContext } from "@/context/AnalysisContext";
import { useAuth } from "@/contexts/auth-context";
import { useLanguage } from "@/contexts/LanguageContext";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Trash2,
Eye,
RefreshCw,
TrendingUp,
FileText,
Download,
MessageCircle,
} from "lucide-react";
import {
getReportsByMarketType,
deleteReport,
deleteReports,
getAllReports,
bulkSaveReports,
type SavedReport,
} from "@/lib/reports-db";
import {
getCloudReports,
deleteCloudReport,
saveCloudReport,
isCloudSyncEnabled,
} from "@/lib/user-api";
// import { LoginPrompt } from "@/components/auth/login-button";
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
// Analyst definitions for download
const ANALYSTS = [
{
key: "market",
label: "市場分析師",
reportKey: "market_report",
description: "技術分析與市場趨勢評估",
},
{
key: "social",
label: "社群媒體分析師",
reportKey: "sentiment_report",
description: "社群情緒與市場氛圍分析",
},
{
key: "news",
label: "新聞分析師",
reportKey: "news_report",
description: "新聞事件與影響分析",
},
{
key: "fundamentals",
label: "基本面分析師",
reportKey: "fundamentals_report",
description: "財務數據與基本面分析",
},
{
key: "bull",
label: "看漲研究員",
reportKey: "investment_debate_state.bull_history",
description: "看漲觀點與投資論據",
},
{
key: "bear",
label: "看跌研究員",
reportKey: "investment_debate_state.bear_history",
description: "看跌觀點與風險警告",
},
{
key: "research_manager",
label: "研究經理",
reportKey: "investment_debate_state.judge_decision",
description: "研究團隊綜合決策",
},
{
key: "trader",
label: "交易員",
reportKey: "trader_investment_plan",
description: "交易執行計劃與策略",
},
{
key: "risky",
label: "激進分析師",
reportKey: "risk_debate_state.risky_history",
description: "高風險高回報策略分析",
},
{
key: "safe",
label: "保守分析師",
reportKey: "risk_debate_state.safe_history",
description: "穩健保守策略分析",
},
{
key: "neutral",
label: "中立分析師",
reportKey: "risk_debate_state.neutral_history",
description: "中立平衡策略分析",
},
{
key: "risk_manager",
label: "風險經理",
reportKey: "risk_debate_state.judge_decision",
description: "風險管理綜合決策",
},
];
// Market type labels - dynamic function to support translations
const getMarketLabels = (t: ReturnType<typeof useLanguage>["t"]) => ({
us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS },
twse: {
label: `🇹🇼 ${t.form.twseMarket}`,
description: t.form.tickerDescTWSE,
},
tpex: {
label: `🇹🇼 ${t.form.tpexMarket}`,
description: t.form.tickerDescTPEX,
},
});
// Helper function to extract decision from Risk Manager's final decision
const extractDecisionFromReport = (
report: SavedReport,
): { action: string; color: string } => {
// DEBUG: Log the actual data structure to diagnose issues
console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker);
console.log(" - result type:", typeof report.result);
console.log(" - result.reports exists:", !!report.result?.reports);
console.log(
" - trader_investment_plan exists:",
!!report.result?.reports?.trader_investment_plan,
);
console.log(" - decision.action exists:", !!report.result?.decision?.action);
if (report.result?.reports?.trader_investment_plan) {
const traderText = report.result.reports.trader_investment_plan;
console.log(" - trader_investment_plan type:", typeof traderText);
console.log(" - trader_investment_plan length:", traderText.length);
// Show last 150 chars to see the final decision
console.log(" - last 150 chars:", traderText.slice(-150));
// Check if it contains the key phrase
const hasProposal = traderText.includes("最終交易提案");
console.log(" - contains '最終交易提案':", hasProposal);
} else {
console.log(" - trader_investment_plan is NULL or undefined");
}
// Helper function to find "最終交易提案" or "Final Trading Proposal"
const findFinalProposal = (
text: string,
): { action: string; color: string } | null => {
if (!text || typeof text !== "string") return null;
// === CHINESE PATTERN ===
// Match "最終交易提案:持有" - handle markdown ** bold markers
// Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有**
// Use global flag to find ALL matches, then take the LAST one (final decision)
const zhRegex =
/\*{0,2}最終交易提案[:]\s*\*{0,2}(買入|賣出|持有)\*{0,2}/g;
const zhMatches = [...text.matchAll(zhRegex)];
if (zhMatches.length > 0) {
// Take the LAST match (the final decision at the end of the report)
const lastMatch = zhMatches[zhMatches.length - 1];
const decision = lastMatch[1];
console.log(
` ✅ Matched ZH pattern: "${lastMatch[0]}" -> decision: "${decision}"`,
);
if (decision === "買入")
return { action: "買入", color: "text-green-600" };
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
if (decision === "持有")
return { action: "持有", color: "text-yellow-600" };
}
// === ENGLISH PATTERN ===
// Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers
// Pattern handles: Final Trading Proposal: Buy, **Final Trading Proposal**: Hold, etc.
const enRegex =
/\*{0,2}Final Trading Proposal\*{0,2}[:]\s*\*{0,2}(BUY|SELL|HOLD|Buy|Sell|Hold)\*{0,2}/gi;
const enMatches = [...text.matchAll(enRegex)];
if (enMatches.length > 0) {
const lastMatch = enMatches[enMatches.length - 1];
const decision = lastMatch[1].toUpperCase();
console.log(
` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`,
);
if (decision === "BUY") return { action: "BUY", color: "text-green-600" };
if (decision === "SELL") return { action: "SELL", color: "text-red-600" };
if (decision === "HOLD")
return { action: "HOLD", color: "text-yellow-600" };
}
return null;
};
// Helper function to find other decision patterns
const findOtherDecision = (
text: string,
): { action: string; color: string } | null => {
if (!text || typeof text !== "string") return null;
const lowerText = text.toLowerCase();
// Look for "最終決策" or "最終建議"
const finalDecisionMatch = text.match(
/最終(?:決策|建議)[:]\s*(買入|賣出|持有)/,
);
if (finalDecisionMatch) {
const decision = finalDecisionMatch[1];
if (decision === "買入")
return { action: "買入", color: "text-green-600" };
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
if (decision === "持有")
return { action: "持有", color: "text-yellow-600" };
}
// English patterns
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) {
return { action: "買入", color: "text-green-600" };
}
if (
lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i)
) {
return { action: "賣出", color: "text-red-600" };
}
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) {
return { action: "持有", color: "text-yellow-600" };
}
return null;
};
// ====== PRIORITY 1: Trader's "最終交易提案" - HIGHEST PRIORITY ======
const traderReport = report.result.reports?.trader_investment_plan;
if (traderReport) {
const decision = findFinalProposal(traderReport);
if (decision) {
console.log(`📊 Found trader decision: ${decision.action}`);
return decision;
}
}
// ====== PRIORITY 2: Check final_trade_decision ======
const finalTradeDecision = report.result.reports?.final_trade_decision;
if (finalTradeDecision) {
const decision =
findFinalProposal(finalTradeDecision) ||
findOtherDecision(finalTradeDecision);
if (decision) return decision;
}
// ====== PRIORITY 3: Check risk_debate_state judge decision ======
const riskJudge = report.result.reports?.risk_debate_state?.judge_decision;
if (riskJudge) {
const decision = findOtherDecision(riskJudge);
if (decision) return decision;
}
// ====== PRIORITY 4: Fall back to decision.action field ======
if (report.result.decision?.action) {
const action = report.result.decision.action;
const actionLower = action.toLowerCase();
const color = actionLower.includes("buy")
? "text-green-600"
: actionLower.includes("sell")
? "text-red-600"
: "text-yellow-600";
return { action, color };
}
// ====== PRIORITY 5: Search in other report fields ======
const allReports = report.result.reports;
if (allReports) {
const reportTexts = [
allReports.market_report,
allReports.sentiment_report,
allReports.news_report,
allReports.fundamentals_report,
].filter((t) => t && typeof t === "string");
for (const text of reportTexts) {
const decision = findFinalProposal(text);
if (decision) return decision;
}
}
return { action: "N/A", color: "text-gray-500" };
};
/**
* Detect report language from content (for backward compatibility with old reports)
* Checks trader_investment_plan for Chinese/English keywords
*/
const detectReportLanguage = (reports: any): "en" | "zh-TW" => {
const traderPlan = reports?.trader_investment_plan;
if (!traderPlan || typeof traderPlan !== "string") {
// If no trader plan, check other reports for Chinese characters
const allText = JSON.stringify(reports || {});
const chineseRegex = /[\u4e00-\u9fa5]/;
return chineseRegex.test(allText) ? "zh-TW" : "en";
}
// Check for Chinese decision keywords
const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"];
for (const keyword of chineseKeywords) {
if (traderPlan.includes(keyword)) {
return "zh-TW";
}
}
// Check for English decision keywords
const englishKeywords = ["buy", "sell", "hold", "final trading proposal"];
const lowerPlan = traderPlan.toLowerCase();
for (const keyword of englishKeywords) {
if (lowerPlan.includes(keyword.toLowerCase())) {
return "en";
}
}
// Fallback: check for Chinese characters in the content
const chineseRegex = /[\u4e00-\u9fa5]/;
return chineseRegex.test(traderPlan) ? "zh-TW" : "en";
};
/**
* Parse a date string from the backend as UTC.
* Backend stores created_at in UTC but may not always include timezone info.
* This ensures the date is correctly interpreted as UTC so the browser
* converts it to the user's local timezone for display.
*/
const parseUTCDate = (dateStr: string): Date => {
// If the string already has timezone info (Z, +, or - offset), parse directly
if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) {
return new Date(dateStr);
}
// Otherwise, append 'Z' to treat as UTC
return new Date(dateStr + 'Z');
};
/**
* Helper to generate a unique signature for deduplication.
* This ensures reports for the same ticker on the same day are not squashed together.
*/
const getReportSignature = (report: any): string => {
const baseKey = `${report.ticker}_${report.analysis_date}_${report.market_type || 'us'}`;
let contentHash = "";
if (report.result) {
if (report.result.reports?.trader_investment_plan) {
const plan = report.result.reports.trader_investment_plan;
contentHash = `${plan.length}_${plan.slice(-30).replace(/[\s\n\r]+/g, '')}`;
} else {
contentHash = JSON.stringify(report.result).length.toString();
}
}
const langKey = report.language || "unknown_lang";
return `${baseKey}_${langKey}_${contentHash}`;
};
export default function HistoryPage() {
const router = useRouter();
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
const { isAuthenticated } = useAuth();
const { t, locale } = useLanguage();
// Dynamic market labels based on language
const MARKET_LABELS = getMarketLabels(t);
const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us");
const [reports, setReports] = useState<SavedReport[]>([]);
const [loading, setLoading] = useState(true);
const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 });
const [isCloudData, setIsCloudData] = useState(false);
// Delete confirmation dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
null,
);
const [deleting, setDeleting] = useState(false);
// Sync state
const [syncing, setSyncing] = useState(false);
// Auto-sync tracking ref
const hasAutoSyncedRef = useRef(false);
const cloudReportsPromiseRef = useRef<Promise<any[]> | null>(null);
const fetchCloudReportsCached = async (forceRefresh = false) => {
if (forceRefresh || !cloudReportsPromiseRef.current) {
cloudReportsPromiseRef.current = getCloudReports().catch(() => {
cloudReportsPromiseRef.current = null;
return [];
});
}
return cloudReportsPromiseRef.current;
};
// Load reports when tab changes, auth state changes, or language changes
useEffect(() => {
loadReports();
}, [activeTab, isAuthenticated, locale]);
// Load counts on mount, auth change, or language change
useEffect(() => {
loadCounts();
}, [isAuthenticated, locale]);
// Bidirectional sync: upload local to cloud AND download cloud to local
const performBidirectionalSync = async (isInitialSync = false) => {
if (!isAuthenticated || !isCloudSyncEnabled()) {
return;
}
// For initial sync, only run once per session
if (isInitialSync && hasAutoSyncedRef.current) {
return;
}
if (isInitialSync) {
hasAutoSyncedRef.current = true;
}
try {
// First auto-clean local duplicates that might exist from older flawed versions
try {
const allLocal = await getAllReports();
const seenSignatures = new Set<string>();
const idsToDelete: number[] = [];
for (const report of allLocal) {
const signature = getReportSignature(report as any);
if (seenSignatures.has(signature)) {
if (report.id) idsToDelete.push(report.id);
} else {
seenSignatures.add(signature);
}
}
if (idsToDelete.length > 0) {
console.log(`🧹 Found ${idsToDelete.length} duplicate local reports, cleaning...`);
await deleteReports(idsToDelete);
}
} catch (err) {
console.error("Failed to cleanup local duplicates:", err);
}
// Get all local reports (re-fetch after cleanup)
const [usLocal, twseLocal, tpexLocal] = await Promise.all([
getReportsByMarketType("us"),
getReportsByMarketType("twse"),
getReportsByMarketType("tpex"),
]);
const allLocal = [...usLocal, ...twseLocal, ...tpexLocal];
// Get cloud reports
const cloudReports = await fetchCloudReportsCached(true); // Force refresh
const cloudKeys = new Set(cloudReports.map((r) => getReportSignature(r)));
const localKeys = new Set(allLocal.map((r) => getReportSignature(r)));
// === UPLOAD: Local -> Cloud ===
const toUpload = allLocal.filter(
(r) => !cloudKeys.has(getReportSignature(r)),
);
if (toUpload.length > 0) {
console.log(`☁️ Sync: Uploading ${toUpload.length} local reports to cloud...`);
let uploadSuccess = 0;
for (const report of toUpload) {
try {
const cloudId = await saveCloudReport({
ticker: report.ticker,
market_type: report.market_type,
analysis_date: report.analysis_date,
result: report.result,
language: report.language || detectReportLanguage(report.result?.reports),
});
if (cloudId) uploadSuccess++;
} catch (e) {
// Silently continue on error
}
}
if (uploadSuccess > 0) {
console.log(`☁️ Sync: Uploaded ${uploadSuccess} reports to cloud`);
}
}
// === DOWNLOAD: Cloud -> Local ===
const toDownload = cloudReports.filter(
(r) => !localKeys.has(getReportSignature(r)),
);
if (toDownload.length > 0) {
console.log(`☁️ Sync: Downloading ${toDownload.length} cloud reports to local...`);
const reportsToSave = toDownload.map((r) => ({
ticker: r.ticker,
market_type: r.market_type as "us" | "twse" | "tpex",
analysis_date: r.analysis_date,
saved_at: parseUTCDate(r.created_at),
result: r.result,
language: (r.language || detectReportLanguage(r.result?.reports)) as "en" | "zh-TW" | undefined,
}));
const savedCount = await bulkSaveReports(reportsToSave);
if (savedCount > 0) {
console.log(`☁️ Sync: Downloaded ${savedCount} reports to local`);
}
}
// === DELETE SYNC: Remove local reports that were deleted from cloud ===
// Only delete reports that are old enough (> 2 minutes) to avoid deleting newly saved reports
const TWO_MINUTES_AGO = Date.now() - 2 * 60 * 1000;
const toDeleteLocal = allLocal.filter((localReport) => {
// Don't delete recently saved reports (might not be uploaded yet)
const savedTime = new Date(localReport.saved_at).getTime();
if (savedTime > TWO_MINUTES_AGO) {
return false;
}
// If local report is not in cloud, it was likely deleted on another device
return !cloudKeys.has(getReportSignature(localReport));
});
if (toDeleteLocal.length > 0) {
console.log(`☁️ Sync: Removing ${toDeleteLocal.length} locally cached reports that were deleted from cloud...`);
const idsToDelete = toDeleteLocal
.map((r) => r.id)
.filter((id): id is number => id !== undefined);
if (idsToDelete.length > 0) {
await deleteReports(idsToDelete);
console.log(`☁️ Sync: Removed ${idsToDelete.length} local reports`);
}
}
// Reload UI if any changes
if (toUpload.length > 0 || toDownload.length > 0 || toDeleteLocal.length > 0) {
await loadReports();
await loadCounts();
} else {
console.log("☁️ Sync: Already in sync");
}
} catch (error) {
console.error("☁️ Sync failed:", error);
}
};
// Initial sync when page loads (if authenticated)
useEffect(() => {
performBidirectionalSync(true);
}, [isAuthenticated]);
// Re-sync when page becomes visible (handles cross-device changes)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && isAuthenticated && isCloudSyncEnabled()) {
console.log("☁️ Page visible, checking for updates...");
performBidirectionalSync(false);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAuthenticated]);
const loadReports = async () => {
setLoading(true);
try {
// Helper to filter reports by current UI language
const filterByLang = (reports: SavedReport[]) => {
return reports.filter((report) => {
const reportLang = report.language || detectReportLanguage(report.result?.reports);
return reportLang === locale;
});
};
// Always load local IndexedDB reports first
const localData = await getReportsByMarketType(activeTab);
// If authenticated, also load from cloud and merge
if (isAuthenticated && isCloudSyncEnabled()) {
const cloudReports = await fetchCloudReportsCached();
// Convert cloud reports to SavedReport format and filter by market type
const cloudFiltered = cloudReports
.filter((r) => r.market_type === activeTab)
.map((r) => ({
id: parseInt(r.id.replace(/-/g, "").slice(0, 8), 16), // Convert UUID to number
cloudId: r.id, // Keep cloud ID for deletion
ticker: r.ticker,
market_type: r.market_type as "us" | "twse" | "tpex",
analysis_date: r.analysis_date,
saved_at: parseUTCDate(r.created_at),
result: r.result,
language: r.language, // Include language from cloud
})) as (SavedReport & { cloudId?: string })[];
if (cloudFiltered.length > 0) {
// Merge: prefer cloud data, but include local-only reports
// Create a Set of cloud report keys (ticker + date + content snippet) for deduplication
const cloudKeys = new Set(
cloudFiltered.map((r) => getReportSignature(r)),
);
// Find local reports that don't exist in cloud
const localOnly = localData.filter(
(r) => !cloudKeys.has(getReportSignature(r)),
);
// Combine: cloud reports + local-only reports
const merged = [...cloudFiltered, ...localOnly];
// Sort by saved_at descending
merged.sort(
(a, b) =>
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(),
);
// Filter by current UI language before display
setReports(filterByLang(merged));
setIsCloudData(true);
return;
}
}
// Filter local data by language before display
setReports(filterByLang(localData));
setIsCloudData(false);
} catch (error) {
console.error("Failed to load reports:", error);
const data = await getReportsByMarketType(activeTab);
// Filter by language on error fallback too
const filtered = data.filter((report) => {
const reportLang = report.language || detectReportLanguage(report.result?.reports);
return reportLang === locale;
});
setReports(filtered as SavedReport[]);
setIsCloudData(false);
} finally {
setLoading(false);
}
};
const loadCounts = async () => {
try {
// Helper to filter reports by language (matches current UI locale)
const filterByLanguage = (reports: SavedReport[]) => {
return reports.filter((report) => {
// Get report language - use stored value or detect from content
const reportLang = report.language || detectReportLanguage(report.result?.reports);
// Match against current locale
return reportLang === locale;
});
};
if (isAuthenticated && isCloudSyncEnabled()) {
const cloudReports = await fetchCloudReportsCached();
if (cloudReports.length > 0) {
// Get local reports to check for duplicates
const [usLocal, twseLocal, tpexLocal] = await Promise.all([
getReportsByMarketType("us"),
getReportsByMarketType("twse"),
getReportsByMarketType("tpex"),
]);
// Cloud report keys for deduplication
const cloudKeys = new Set(
cloudReports.map(r => getReportSignature(r))
);
// Convert cloud reports to SavedReport format for language filtering
const cloudAsSaved = cloudReports.map(r => ({
id: 0,
ticker: r.ticker,
market_type: r.market_type as "us" | "twse" | "tpex",
analysis_date: r.analysis_date,
saved_at: parseUTCDate(r.created_at),
result: r.result,
language: r.language,
})) as SavedReport[];
// Filter cloud reports by language
const cloudFiltered = filterByLanguage(cloudAsSaved);
// Count local-only reports (not in cloud) and filter by language
const usLocalOnly = filterByLanguage(usLocal.filter(
r => !cloudKeys.has(getReportSignature(r))
)).length;
const twseLocalOnly = filterByLanguage(twseLocal.filter(
r => !cloudKeys.has(getReportSignature(r))
)).length;
const tpexLocalOnly = filterByLanguage(tpexLocal.filter(
r => !cloudKeys.has(getReportSignature(r))
)).length;
// Cloud counts (already filtered by language)
const usCloud = cloudFiltered.filter(r => r.market_type === "us").length;
const twseCloud = cloudFiltered.filter(r => r.market_type === "twse").length;
const tpexCloud = cloudFiltered.filter(r => r.market_type === "tpex").length;
// Merged counts: cloud + local-only (both filtered by language)
setCounts({
us: usCloud + usLocalOnly,
twse: twseCloud + twseLocalOnly,
tpex: tpexCloud + tpexLocalOnly,
});
return;
}
}
// If no cloud data or not authenticated, use local only with language filter
const [usLocal, twseLocal, tpexLocal] = await Promise.all([
getReportsByMarketType("us"),
getReportsByMarketType("twse"),
getReportsByMarketType("tpex"),
]);
setCounts({
us: filterByLanguage(usLocal).length,
twse: filterByLanguage(twseLocal).length,
tpex: filterByLanguage(tpexLocal).length,
});
} catch (error) {
console.error("Failed to load counts:", error);
}
};
// Handle refresh button - performs full sync if authenticated
const handleRefresh = async () => {
if (isAuthenticated && isCloudSyncEnabled()) {
setSyncing(true);
try {
await performBidirectionalSync(false);
} finally {
setSyncing(false);
}
} else {
await loadReports();
}
};
const handleViewReport = (report: SavedReport) => {
// Set the context with the saved report data
setAnalysisResult(report.result);
setTaskId(report.task_id || null);
setMarketType(report.market_type);
// Navigate to results page
router.push("/analysis/results");
};
const handleDeleteClick = (report: SavedReport) => {
setReportToDelete(report);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!reportToDelete) return;
setDeleting(true);
try {
const cloudId = (reportToDelete as any).cloudId;
// IMPORTANT: Delete from BOTH cloud AND local to prevent re-sync issues
// 1. If cloud ID exists, delete from cloud
if (cloudId) {
console.log("🗑️ Deleting from cloud:", cloudId);
await deleteCloudReport(cloudId);
}
// 2. Always try to delete from local IndexedDB as well
// Use strict matching: ticker + date + market_type + language
try {
const localReports = await getReportsByMarketType(
reportToDelete.market_type,
);
// Get language of report to delete (use stored or detect)
const targetLang = reportToDelete.language || detectReportLanguage(reportToDelete.result?.reports);
// Find matching report with same ticker, date, market, AND language
const matchingLocal = localReports.find((r) => {
if (r.ticker !== reportToDelete.ticker) return false;
if (r.analysis_date !== reportToDelete.analysis_date) return false;
if (r.market_type !== reportToDelete.market_type) return false;
// Match language (detect if not stored)
const localLang = r.language || detectReportLanguage(r.result?.reports);
return localLang === targetLang;
});
if (matchingLocal && matchingLocal.id) {
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id, "language:", targetLang);
await deleteReport(matchingLocal.id);
}
} catch (localError) {
console.warn("Could not delete local copy:", localError);
}
// Refresh reports and counts
await fetchCloudReportsCached(true);
await loadReports();
await loadCounts();
setDeleteDialogOpen(false);
setReportToDelete(null);
} catch (error) {
console.error("Failed to delete report:", error);
} finally {
setDeleting(false);
}
};
// Download PDF handler
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const handleDownloadPdf = async (report: SavedReport) => {
setDownloadingId(report.id ?? null);
try {
// Get all available analyst keys
const getNestedValue = (obj: any, path: string) => {
return path.split(".").reduce((current, key) => current?.[key], obj);
};
const availableAnalystKeys = ANALYSTS.filter((analyst) => {
const reportContent = getNestedValue(
report.result.reports,
analyst.reportKey,
);
return reportContent && reportContent.trim().length > 0;
}).map((a) => a.key);
if (availableAnalystKeys.length === 0) {
alert("此報告沒有可下載的分析師報告");
return;
}
// Detect report language based on trader's final decision keywords
// This ensures PDF language matches the report content, not UI language
const detectReportLanguage = (reports: any): "zh-TW" | "en" => {
const traderPlan = reports?.trader_investment_plan;
if (!traderPlan || typeof traderPlan !== "string") {
return "zh-TW"; // Default to Chinese
}
// Chinese decision keywords: 買入, 賣出, 持有
const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"];
// English decision keywords: buy, sell, hold (case insensitive)
const englishKeywords = ["buy", "sell", "hold", "final trading proposal"];
const lowerPlan = traderPlan.toLowerCase();
// Check for Chinese keywords first
for (const keyword of chineseKeywords) {
if (traderPlan.includes(keyword)) {
return "zh-TW";
}
}
// Check for English keywords
for (const keyword of englishKeywords) {
if (lowerPlan.includes(keyword)) {
return "en";
}
}
// Fallback: check for any Chinese characters
const chineseRegex = /[\u4e00-\u9fa5]/;
if (chineseRegex.test(traderPlan)) {
return "zh-TW";
}
return "en";
};
const reportLanguage = detectReportLanguage(report.result.reports);
// Build request body
const requestBody = {
ticker: report.ticker,
analysis_date: report.analysis_date,
analysts: availableAnalystKeys,
reports: report.result.reports,
price_data: report.result.price_data,
price_stats: report.result.price_stats,
language: reportLanguage, // Use detected language based on trader decision
};
const response = await fetch("/api/download/reports", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `下載失敗 (${response.status})`);
}
// Get the blob
const blob = await response.blob();
// Get filename from header
const contentDisposition = response.headers.get("Content-Disposition");
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error: any) {
console.error("Download error:", error);
alert(error.message || "下載失敗,請稍後再試");
} finally {
setDownloadingId(null);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 via-pink-50/20 to-purple-50/30 dark:from-gray-950 dark:via-purple-950/40 dark:to-gray-950">
<div className="container mx-auto px-4 py-12">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="text-center relative animate-fade-in">
<div className="absolute inset-0 gradient-bg-radial opacity-40 -z-10" />
<h1 className="text-4xl font-bold mb-2 gradient-text-primary">
{t.history.title}
</h1>
<p className="text-gray-600 dark:text-gray-400">
{t.history.noHistory.replace(
"尚無分析紀錄",
"瀏覽已儲存的分析報告",
)}
</p>
</div>
{/* Pending Task Recovery Notice */}
<PendingTaskRecovery />
{/* Market Type Tabs */}
<Tabs
value={activeTab}
onValueChange={(v: string) => setActiveTab(v as typeof activeTab)}
className="w-full animate-slide-up animate-delay-200"
>
<TabsList className="grid w-full grid-cols-1 sm:grid-cols-3 h-auto gap-2">
{(
Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
).map((key) => (
<TabsTrigger
key={key}
value={key}
className="py-2 sm:py-3 text-sm sm:text-base transition-all duration-300 hover:scale-105"
>
<span className="mr-1 sm:mr-2">{MARKET_LABELS[key].label}</span>
<span className="px-1.5 sm:px-2 py-0.5 rounded-full bg-white/20 text-xs">
{counts[key]}
</span>
</TabsTrigger>
))}
</TabsList>
{(
Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
).map((marketType) => (
<TabsContent key={marketType} value={marketType} className="mt-6">
<div className="space-y-4">
{/* Refresh/Sync button */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading || syncing}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${loading || syncing ? "animate-spin" : ""}`}
/>
{syncing ? t.history.syncing : t.history.refresh}
</Button>
</div>
{/* Report List */}
{loading ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-gray-400" />
<p className="text-gray-500 mt-4">{t.history.loading}</p>
</div>
) : reports.length === 0 ? (
<Card className="animate-fade-in">
<CardContent className="py-12 text-center">
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t.history.noReportsFor}{" "}
{MARKET_LABELS[marketType].label}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
{t.history.afterAnalysisSave}
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push("/analysis")}
>
{t.home.startAnalysis}
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{reports.map((report) => (
<Card
key={report.id}
className="hover-lift animate-scale-up transition-all duration-300"
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="text-xl font-bold gradient-text-primary">
{report.ticker}
</span>
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-blue-100 to-pink-100 dark:from-blue-900 dark:to-purple-900 text-gray-600 dark:text-gray-300">
{MARKET_LABELS[report.market_type].label}
</span>
</CardTitle>
<CardDescription>
{t.history.analysisDate}{report.analysis_date}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t.history.savedAt}
{format(
new Date(report.saved_at),
"yyyy/MM/dd HH:mm",
{ locale: zhTW },
)}
</p>
{(() => {
const decision =
extractDecisionFromReport(report);
return (
<p className="text-sm mt-2">
<span className="font-medium">
{t.history.decision}
</span>
<span
className={`ml-1 font-semibold ${decision.color}`}
>
{decision.action}
</span>
</p>
);
})()}
</CardContent>
<CardFooter className="grid grid-cols-2 gap-2">
<Button
variant="default"
size="sm"
className="w-full gap-1"
onClick={() => handleViewReport(report)}
>
<Eye className="h-4 w-4" />
{t.history.view}
</Button>
<Button
variant="outline"
size="sm"
className="w-full gap-1"
onClick={() => handleDownloadPdf(report)}
disabled={downloadingId === report.id}
>
{downloadingId === report.id ? (
<>
<Download className="h-4 w-4 animate-bounce" />
{t.history.downloading}
</>
) : (
<>
<FileText className="h-4 w-4" />
PDF
</>
)}
</Button>
<Button
variant="outline"
size="sm"
className="col-span-2 w-full gap-2 text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-800 hover:bg-purple-50 dark:hover:bg-purple-950/50"
onClick={() => router.push(`/history/chat?ticker=${report.ticker}&date=${report.analysis_date}&market=${report.market_type}`)}
>
<MessageCircle className="h-4 w-4" />
{t.chat?.title || "Report Chat"} {t.chat?.allReports || "All Reports"}
</Button>
<Button
variant="destructive"
size="sm"
className="col-span-2 w-full gap-1"
onClick={() => handleDeleteClick(report)}
>
<Trash2 className="h-4 w-4" />
{t.history.delete}
</Button>
</CardFooter>
</Card>
))}
</div>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.history.confirmDeleteTitle}</DialogTitle>
<DialogDescription>
{t.history.confirmDeleteDesc}{" "}
<strong>{reportToDelete?.ticker}</strong> {t.history.on}{" "}
<strong>{reportToDelete?.analysis_date}</strong>?
<br />
<span className="text-red-500">{t.history.cannotUndo}</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
>
{t.history.cancel}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deleting}
>
{deleting ? t.history.deleting : t.history.confirmDeleteBtn}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}