This commit is contained in:
MarkLo127 2026-01-27 15:34:37 +08:00
parent d58e80a962
commit 70d763beca
1 changed files with 475 additions and 319 deletions

View File

@ -28,48 +28,130 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Trash2, Eye, RefreshCw, TrendingUp, FileText, Download } from "lucide-react"; import {
Trash2,
Eye,
RefreshCw,
TrendingUp,
FileText,
Download,
} from "lucide-react";
import { import {
getReportsByMarketType, getReportsByMarketType,
deleteReport, deleteReport,
getReportCountByMarketType, getReportCountByMarketType,
type SavedReport, type SavedReport,
} from "@/lib/reports-db"; } from "@/lib/reports-db";
import { getCloudReports, deleteCloudReport, saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; import {
getCloudReports,
deleteCloudReport,
saveCloudReport,
isCloudSyncEnabled,
} from "@/lib/user-api";
// import { LoginPrompt } from "@/components/auth/login-button"; // import { LoginPrompt } from "@/components/auth/login-button";
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery"; import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
// Analyst definitions for download // Analyst definitions for download
const ANALYSTS = [ const ANALYSTS = [
{ key: "market", label: "市場分析師", reportKey: "market_report", description: "技術分析與市場趨勢評估" }, {
{ key: "social", label: "社群媒體分析師", reportKey: "sentiment_report", description: "社群情緒與市場氛圍分析" }, key: "market",
{ key: "news", label: "新聞分析師", reportKey: "news_report", description: "新聞事件與影響分析" }, label: "市場分析師",
{ key: "fundamentals", label: "基本面分析師", reportKey: "fundamentals_report", description: "財務數據與基本面分析" }, reportKey: "market_report",
{ key: "bull", label: "看漲研究員", reportKey: "investment_debate_state.bull_history", description: "看漲觀點與投資論據" }, 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: "social",
{ key: "risky", label: "激進分析師", reportKey: "risk_debate_state.risky_history", description: "高風險高回報策略分析" }, label: "社群媒體分析師",
{ key: "safe", label: "保守分析師", reportKey: "risk_debate_state.safe_history", description: "穩健保守策略分析" }, reportKey: "sentiment_report",
{ key: "neutral", label: "中立分析師", reportKey: "risk_debate_state.neutral_history", description: "中立平衡策略分析" }, description: "社群情緒與市場氛圍分析",
{ key: "risk_manager", label: "風險經理", reportKey: "risk_debate_state.judge_decision", 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 // Market type labels - dynamic function to support translations
const getMarketLabels = (t: ReturnType<typeof useLanguage>['t']) => ({ const getMarketLabels = (t: ReturnType<typeof useLanguage>["t"]) => ({
us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS }, us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS },
twse: { label: `🇹🇼 ${t.form.twseMarket}`, description: t.form.tickerDescTWSE }, twse: {
tpex: { label: `🇹🇼 ${t.form.tpexMarket}`, description: t.form.tickerDescTPEX }, 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 // Helper function to extract decision from Risk Manager's final decision
const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => { const extractDecisionFromReport = (
report: SavedReport,
): { action: string; color: string } => {
// DEBUG: Log the actual data structure to diagnose issues // DEBUG: Log the actual data structure to diagnose issues
console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker); console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker);
console.log(" - result type:", typeof report.result); console.log(" - result type:", typeof report.result);
console.log(" - result.reports exists:", !!report.result?.reports); console.log(" - result.reports exists:", !!report.result?.reports);
console.log(" - trader_investment_plan exists:", !!report.result?.reports?.trader_investment_plan); console.log(
" - trader_investment_plan exists:",
!!report.result?.reports?.trader_investment_plan,
);
console.log(" - decision.action exists:", !!report.result?.decision?.action); console.log(" - decision.action exists:", !!report.result?.decision?.action);
if (report.result?.reports?.trader_investment_plan) { if (report.result?.reports?.trader_investment_plan) {
@ -85,64 +167,83 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
console.log(" - trader_investment_plan is NULL or undefined"); console.log(" - trader_investment_plan is NULL or undefined");
} }
// Helper function to find "最終交易提案" or "Final Trading Proposal" // Helper function to find "最終交易提案" or "Final Trading Proposal"
const findFinalProposal = (text: string): { action: string; color: string } | null => { const findFinalProposal = (
if (!text || typeof text !== 'string') return null; text: string,
): { action: string; color: string } | null => {
if (!text || typeof text !== "string") return null;
// === CHINESE PATTERN === // === CHINESE PATTERN ===
// Match "最終交易提案:持有" - handle markdown ** bold markers // Match "最終交易提案:持有" - handle markdown ** bold markers
// Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有** // Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有**
// Use global flag to find ALL matches, then take the LAST one (final decision) // 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 zhRegex =
/\*{0,2}最終交易提案[:]\s*\*{0,2}(買入|賣出|持有)\*{0,2}/g;
const zhMatches = [...text.matchAll(zhRegex)]; const zhMatches = [...text.matchAll(zhRegex)];
if (zhMatches.length > 0) { if (zhMatches.length > 0) {
// Take the LAST match (the final decision at the end of the report) // Take the LAST match (the final decision at the end of the report)
const lastMatch = zhMatches[zhMatches.length - 1]; const lastMatch = zhMatches[zhMatches.length - 1];
const decision = lastMatch[1]; const decision = lastMatch[1];
console.log(` ✅ Matched ZH pattern: "${lastMatch[0]}" -> decision: "${decision}"`); console.log(
if (decision === "買入") return { action: "買入", color: "text-green-600" }; ` ✅ 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-red-600" };
if (decision === "持有") return { action: "持有", color: "text-yellow-600" }; if (decision === "持有")
return { action: "持有", color: "text-yellow-600" };
} }
// === ENGLISH PATTERN === // === ENGLISH PATTERN ===
// Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers // Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers
// Pattern handles: Final Trading Proposal: Buy, **Final Trading Proposal**: Hold, etc. // 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 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)]; const enMatches = [...text.matchAll(enRegex)];
if (enMatches.length > 0) { if (enMatches.length > 0) {
const lastMatch = enMatches[enMatches.length - 1]; const lastMatch = enMatches[enMatches.length - 1];
const decision = lastMatch[1].toUpperCase(); const decision = lastMatch[1].toUpperCase();
console.log(` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`); console.log(
` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`,
);
if (decision === "BUY") return { action: "BUY", color: "text-green-600" }; if (decision === "BUY") return { action: "BUY", color: "text-green-600" };
if (decision === "SELL") return { action: "SELL", color: "text-red-600" }; if (decision === "SELL") return { action: "SELL", color: "text-red-600" };
if (decision === "HOLD") return { action: "HOLD", color: "text-yellow-600" }; if (decision === "HOLD")
return { action: "HOLD", color: "text-yellow-600" };
} }
return null; return null;
}; };
// Helper function to find other decision patterns // Helper function to find other decision patterns
const findOtherDecision = (text: string): { action: string; color: string } | null => { const findOtherDecision = (
if (!text || typeof text !== 'string') return null; text: string,
): { action: string; color: string } | null => {
if (!text || typeof text !== "string") return null;
const lowerText = text.toLowerCase(); const lowerText = text.toLowerCase();
// Look for "最終決策" or "最終建議" // Look for "最終決策" or "最終建議"
const finalDecisionMatch = text.match(/最終(?:決策|建議)[:]\s*(買入|賣出|持有)/); const finalDecisionMatch = text.match(
/最終(?:決策|建議)[:]\s*(買入|賣出|持有)/,
);
if (finalDecisionMatch) { if (finalDecisionMatch) {
const decision = finalDecisionMatch[1]; const decision = finalDecisionMatch[1];
if (decision === "買入") return { action: "買入", color: "text-green-600" }; if (decision === "買入")
return { action: "買入", color: "text-green-600" };
if (decision === "賣出") return { action: "賣出", color: "text-red-600" }; if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
if (decision === "持有") return { action: "持有", color: "text-yellow-600" }; if (decision === "持有")
return { action: "持有", color: "text-yellow-600" };
} }
// English patterns // English patterns
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) { if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) {
return { action: "買入", color: "text-green-600" }; return { action: "買入", color: "text-green-600" };
} }
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i)) { if (
lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i)
) {
return { action: "賣出", color: "text-red-600" }; return { action: "賣出", color: "text-red-600" };
} }
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) { if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) {
@ -165,7 +266,9 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
// ====== PRIORITY 2: Check final_trade_decision ====== // ====== PRIORITY 2: Check final_trade_decision ======
const finalTradeDecision = report.result.reports?.final_trade_decision; const finalTradeDecision = report.result.reports?.final_trade_decision;
if (finalTradeDecision) { if (finalTradeDecision) {
const decision = findFinalProposal(finalTradeDecision) || findOtherDecision(finalTradeDecision); const decision =
findFinalProposal(finalTradeDecision) ||
findOtherDecision(finalTradeDecision);
if (decision) return decision; if (decision) return decision;
} }
@ -196,7 +299,7 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
allReports.sentiment_report, allReports.sentiment_report,
allReports.news_report, allReports.news_report,
allReports.fundamentals_report, allReports.fundamentals_report,
].filter(t => t && typeof t === 'string'); ].filter((t) => t && typeof t === "string");
for (const text of reportTexts) { for (const text of reportTexts) {
const decision = findFinalProposal(text); const decision = findFinalProposal(text);
@ -213,33 +316,33 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
*/ */
const detectReportLanguage = (reports: any): "en" | "zh-TW" => { const detectReportLanguage = (reports: any): "en" | "zh-TW" => {
const traderPlan = reports?.trader_investment_plan; const traderPlan = reports?.trader_investment_plan;
if (!traderPlan || typeof traderPlan !== 'string') { if (!traderPlan || typeof traderPlan !== "string") {
// If no trader plan, check other reports for Chinese characters // If no trader plan, check other reports for Chinese characters
const allText = JSON.stringify(reports || {}); const allText = JSON.stringify(reports || {});
const chineseRegex = /[\u4e00-\u9fa5]/; const chineseRegex = /[\u4e00-\u9fa5]/;
return chineseRegex.test(allText) ? 'zh-TW' : 'en'; return chineseRegex.test(allText) ? "zh-TW" : "en";
} }
// Check for Chinese decision keywords // Check for Chinese decision keywords
const chineseKeywords = ['買入', '賣出', '持有', '最終交易提案']; const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"];
for (const keyword of chineseKeywords) { for (const keyword of chineseKeywords) {
if (traderPlan.includes(keyword)) { if (traderPlan.includes(keyword)) {
return 'zh-TW'; return "zh-TW";
} }
} }
// Check for English decision keywords // Check for English decision keywords
const englishKeywords = ['buy', 'sell', 'hold', 'Final Trading Proposal']; const englishKeywords = ["buy", "sell", "hold", "Final Trading Proposal"];
const lowerPlan = traderPlan.toLowerCase(); const lowerPlan = traderPlan.toLowerCase();
for (const keyword of englishKeywords) { for (const keyword of englishKeywords) {
if (lowerPlan.includes(keyword.toLowerCase())) { if (lowerPlan.includes(keyword.toLowerCase())) {
return 'en'; return "en";
} }
} }
// Fallback: check for Chinese characters in the content // Fallback: check for Chinese characters in the content
const chineseRegex = /[\u4e00-\u9fa5]/; const chineseRegex = /[\u4e00-\u9fa5]/;
return chineseRegex.test(traderPlan) ? 'zh-TW' : 'en'; return chineseRegex.test(traderPlan) ? "zh-TW" : "en";
}; };
export default function HistoryPage() { export default function HistoryPage() {
@ -260,7 +363,7 @@ export default function HistoryPage() {
// Delete confirmation dialog // Delete confirmation dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>( const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
null null,
); );
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@ -272,16 +375,20 @@ export default function HistoryPage() {
loadReports(); loadReports();
}, [activeTab, isAuthenticated, locale]); }, [activeTab, isAuthenticated, locale]);
// Load counts on mount or auth change // Load counts on mount, auth change, or language change
useEffect(() => { useEffect(() => {
loadCounts(); loadCounts();
}, [isAuthenticated]); }, [isAuthenticated, locale]);
// Auto-sync local reports to cloud when page loads (if authenticated) // Auto-sync local reports to cloud when page loads (if authenticated)
useEffect(() => { useEffect(() => {
const autoSync = async () => { const autoSync = async () => {
// Only sync once per session, and only if authenticated // Only sync once per session, and only if authenticated
if (hasAutoSyncedRef.current || !isAuthenticated || !isCloudSyncEnabled()) { if (
hasAutoSyncedRef.current ||
!isAuthenticated ||
!isCloudSyncEnabled()
) {
return; return;
} }
@ -301,12 +408,12 @@ export default function HistoryPage() {
// Get cloud reports to check for duplicates // Get cloud reports to check for duplicates
const cloudReports = await getCloudReports(); const cloudReports = await getCloudReports();
const cloudKeys = new Set( const cloudKeys = new Set(
cloudReports.map(r => `${r.ticker}_${r.analysis_date}`) cloudReports.map((r) => `${r.ticker}_${r.analysis_date}`),
); );
// Find local-only reports to upload // Find local-only reports to upload
const toUpload = allLocal.filter( const toUpload = allLocal.filter(
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`) (r) => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`),
); );
if (toUpload.length === 0) { if (toUpload.length === 0) {
@ -314,7 +421,9 @@ export default function HistoryPage() {
return; return;
} }
console.log(`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`); console.log(
`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`,
);
// Upload each report silently // Upload each report silently
let success = 0; let success = 0;
@ -358,9 +467,9 @@ export default function HistoryPage() {
// Convert cloud reports to SavedReport format and filter by market type // Convert cloud reports to SavedReport format and filter by market type
const cloudFiltered = cloudReports const cloudFiltered = cloudReports
.filter(r => r.market_type === activeTab) .filter((r) => r.market_type === activeTab)
.map(r => ({ .map((r) => ({
id: parseInt(r.id.replace(/-/g, '').slice(0, 8), 16), // Convert UUID to number id: parseInt(r.id.replace(/-/g, "").slice(0, 8), 16), // Convert UUID to number
cloudId: r.id, // Keep cloud ID for deletion cloudId: r.id, // Keep cloud ID for deletion
ticker: r.ticker, ticker: r.ticker,
market_type: r.market_type as "us" | "twse" | "tpex", market_type: r.market_type as "us" | "twse" | "tpex",
@ -374,24 +483,25 @@ export default function HistoryPage() {
// Merge: prefer cloud data, but include local-only reports // Merge: prefer cloud data, but include local-only reports
// Create a Set of cloud report keys (ticker + date) for deduplication // Create a Set of cloud report keys (ticker + date) for deduplication
const cloudKeys = new Set( const cloudKeys = new Set(
cloudFiltered.map(r => `${r.ticker}_${r.analysis_date}`) cloudFiltered.map((r) => `${r.ticker}_${r.analysis_date}`),
); );
// Find local reports that don't exist in cloud // Find local reports that don't exist in cloud
const localOnly = localData.filter( const localOnly = localData.filter(
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`) (r) => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`),
); );
// Combine: cloud reports + local-only reports // Combine: cloud reports + local-only reports
const merged = [...cloudFiltered, ...localOnly]; const merged = [...cloudFiltered, ...localOnly];
// Sort by saved_at descending // Sort by saved_at descending
merged.sort((a, b) => merged.sort(
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime() (a, b) =>
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(),
); );
// Filter by current language // Filter by current language
const languageFiltered = merged.filter(report => { const languageFiltered = merged.filter((report) => {
// Use stored language if available // Use stored language if available
if (report.language) { if (report.language) {
return report.language === locale; return report.language === locale;
@ -408,7 +518,7 @@ export default function HistoryPage() {
// If no cloud data or not authenticated, use local only // If no cloud data or not authenticated, use local only
// Filter by current language // Filter by current language
const languageFiltered = localData.filter(report => { const languageFiltered = localData.filter((report) => {
if (report.language) { if (report.language) {
return report.language === locale; return report.language === locale;
} }
@ -420,7 +530,7 @@ export default function HistoryPage() {
console.error("Failed to load reports:", error); console.error("Failed to load reports:", error);
// Fall back to local on error // Fall back to local on error
const data = await getReportsByMarketType(activeTab); const data = await getReportsByMarketType(activeTab);
const languageFiltered = data.filter(report => { const languageFiltered = data.filter((report) => {
if (report.language) { if (report.language) {
return report.language === locale; return report.language === locale;
} }
@ -435,8 +545,15 @@ export default function HistoryPage() {
const loadCounts = async () => { const loadCounts = async () => {
try { try {
// Always get local counts first // Helper to filter reports by language
const localCounts = await getReportCountByMarketType(); const filterByLanguage = (reports: SavedReport[]) => {
return reports.filter(report => {
if (report.language) {
return report.language === locale;
}
return detectReportLanguage(report.result?.reports) === locale;
});
};
if (isAuthenticated && isCloudSyncEnabled()) { if (isAuthenticated && isCloudSyncEnabled()) {
const cloudReports = await getCloudReports(); const cloudReports = await getCloudReports();
@ -454,25 +571,39 @@ export default function HistoryPage() {
cloudReports.map(r => `${r.ticker}_${r.analysis_date}_${r.market_type}`) cloudReports.map(r => `${r.ticker}_${r.analysis_date}_${r.market_type}`)
); );
// Count local-only reports (not in cloud) // Convert cloud reports to SavedReport format for language filtering
const usLocalOnly = usLocal.filter( 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: new Date(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(`${r.ticker}_${r.analysis_date}_us`) r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_us`)
).length; )).length;
const twseLocalOnly = twseLocal.filter( const twseLocalOnly = filterByLanguage(twseLocal.filter(
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_twse`) r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_twse`)
).length; )).length;
const tpexLocalOnly = tpexLocal.filter( const tpexLocalOnly = filterByLanguage(tpexLocal.filter(
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_tpex`) r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_tpex`)
).length; )).length;
// Cloud counts // Cloud counts (already filtered by language)
const usCoud = cloudReports.filter(r => r.market_type === "us").length; const usCloud = cloudFiltered.filter(r => r.market_type === "us").length;
const twseCloud = cloudReports.filter(r => r.market_type === "twse").length; const twseCloud = cloudFiltered.filter(r => r.market_type === "twse").length;
const tpexCloud = cloudReports.filter(r => r.market_type === "tpex").length; const tpexCloud = cloudFiltered.filter(r => r.market_type === "tpex").length;
// Merged counts: cloud + local-only // Merged counts: cloud + local-only (both filtered by language)
setCounts({ setCounts({
us: usCoud + usLocalOnly, us: usCloud + usLocalOnly,
twse: twseCloud + twseLocalOnly, twse: twseCloud + twseLocalOnly,
tpex: tpexCloud + tpexLocalOnly, tpex: tpexCloud + tpexLocalOnly,
}); });
@ -480,7 +611,18 @@ export default function HistoryPage() {
} }
} }
setCounts(localCounts); // 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) { } catch (error) {
console.error("Failed to load counts:", error); console.error("Failed to load counts:", error);
} }
@ -517,10 +659,13 @@ export default function HistoryPage() {
// 2. Always try to delete from local IndexedDB as well // 2. Always try to delete from local IndexedDB as well
// Find matching local report by ticker + analysis_date // Find matching local report by ticker + analysis_date
try { try {
const localReports = await getReportsByMarketType(reportToDelete.market_type); const localReports = await getReportsByMarketType(
reportToDelete.market_type,
);
const matchingLocal = localReports.find( const matchingLocal = localReports.find(
r => r.ticker === reportToDelete.ticker && (r) =>
r.analysis_date === reportToDelete.analysis_date r.ticker === reportToDelete.ticker &&
r.analysis_date === reportToDelete.analysis_date,
); );
if (matchingLocal && matchingLocal.id) { if (matchingLocal && matchingLocal.id) {
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id); console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id);
@ -550,57 +695,58 @@ export default function HistoryPage() {
try { try {
// Get all available analyst keys // Get all available analyst keys
const getNestedValue = (obj: any, path: string) => { const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj); return path.split(".").reduce((current, key) => current?.[key], obj);
}; };
const availableAnalystKeys = ANALYSTS const availableAnalystKeys = ANALYSTS.filter((analyst) => {
.filter(analyst => { const reportContent = getNestedValue(
const reportContent = getNestedValue(report.result.reports, analyst.reportKey); report.result.reports,
analyst.reportKey,
);
return reportContent && reportContent.trim().length > 0; return reportContent && reportContent.trim().length > 0;
}) }).map((a) => a.key);
.map(a => a.key);
if (availableAnalystKeys.length === 0) { if (availableAnalystKeys.length === 0) {
alert('此報告沒有可下載的分析師報告'); alert("此報告沒有可下載的分析師報告");
return; return;
} }
// Detect report language based on trader's final decision keywords // Detect report language based on trader's final decision keywords
// This ensures PDF language matches the report content, not UI language // This ensures PDF language matches the report content, not UI language
const detectReportLanguage = (reports: any): 'zh-TW' | 'en' => { const detectReportLanguage = (reports: any): "zh-TW" | "en" => {
const traderPlan = reports?.trader_investment_plan; const traderPlan = reports?.trader_investment_plan;
if (!traderPlan || typeof traderPlan !== 'string') { if (!traderPlan || typeof traderPlan !== "string") {
return 'zh-TW'; // Default to Chinese return "zh-TW"; // Default to Chinese
} }
// Chinese decision keywords: 買入, 賣出, 持有 // Chinese decision keywords: 買入, 賣出, 持有
const chineseKeywords = ['買入', '賣出', '持有']; const chineseKeywords = ["買入", "賣出", "持有"];
// English decision keywords: buy, sell, hold (case insensitive) // English decision keywords: buy, sell, hold (case insensitive)
const englishKeywords = ['buy', 'sell', 'hold']; const englishKeywords = ["buy", "sell", "hold"];
const lowerPlan = traderPlan.toLowerCase(); const lowerPlan = traderPlan.toLowerCase();
// Check for Chinese keywords first // Check for Chinese keywords first
for (const keyword of chineseKeywords) { for (const keyword of chineseKeywords) {
if (traderPlan.includes(keyword)) { if (traderPlan.includes(keyword)) {
return 'zh-TW'; return "zh-TW";
} }
} }
// Check for English keywords // Check for English keywords
for (const keyword of englishKeywords) { for (const keyword of englishKeywords) {
if (lowerPlan.includes(keyword)) { if (lowerPlan.includes(keyword)) {
return 'en'; return "en";
} }
} }
// Fallback: check for any Chinese characters // Fallback: check for any Chinese characters
const chineseRegex = /[\u4e00-\u9fa5]/; const chineseRegex = /[\u4e00-\u9fa5]/;
if (chineseRegex.test(traderPlan)) { if (chineseRegex.test(traderPlan)) {
return 'zh-TW'; return "zh-TW";
} }
return 'en'; return "en";
}; };
const reportLanguage = detectReportLanguage(report.result.reports); const reportLanguage = detectReportLanguage(report.result.reports);
@ -616,10 +762,10 @@ export default function HistoryPage() {
language: reportLanguage, // Use detected language based on trader decision language: reportLanguage, // Use detected language based on trader decision
}; };
const response = await fetch('/api/download/reports', { const response = await fetch("/api/download/reports", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
@ -633,7 +779,7 @@ export default function HistoryPage() {
const blob = await response.blob(); const blob = await response.blob();
// Get filename from header // Get filename from header
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get("Content-Disposition");
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`; let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
if (contentDisposition) { if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/); const filenameMatch = contentDisposition.match(/filename=(.+)/);
@ -644,7 +790,7 @@ export default function HistoryPage() {
// Create download link // Create download link
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = filename; link.download = filename;
document.body.appendChild(link); document.body.appendChild(link);
@ -654,8 +800,8 @@ export default function HistoryPage() {
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (error: any) { } catch (error: any) {
console.error('Download error:', error); console.error("Download error:", error);
alert(error.message || '下載失敗,請稍後再試'); alert(error.message || "下載失敗,請稍後再試");
} finally { } finally {
setDownloadingId(null); setDownloadingId(null);
} }
@ -672,7 +818,10 @@ export default function HistoryPage() {
{t.history.title} {t.history.title}
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600 dark:text-gray-400">
{t.history.noHistory.replace("尚無分析紀錄", "瀏覽已儲存的分析報告")} {t.history.noHistory.replace(
"尚無分析紀錄",
"瀏覽已儲存的分析報告",
)}
</p> </p>
</div> </div>
@ -685,25 +834,26 @@ export default function HistoryPage() {
onValueChange={(v) => setActiveTab(v as typeof activeTab)} onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="w-full animate-slide-up animate-delay-200" className="w-full animate-slide-up animate-delay-200"
> >
<TabsList className="grid w-full grid-cols-3 h-auto gap-2"> <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) => ( Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
).map((key) => (
<TabsTrigger <TabsTrigger
key={key} key={key}
value={key} value={key}
className="py-3 text-base transition-all duration-300 hover:scale-105" className="py-2 sm:py-3 text-sm sm:text-base transition-all duration-300 hover:scale-105"
> >
<span className="mr-2">{MARKET_LABELS[key].label}</span> <span className="mr-1 sm:mr-2">{MARKET_LABELS[key].label}</span>
<span className="px-2 py-0.5 rounded-full bg-white/20 text-xs"> <span className="px-1.5 sm:px-2 py-0.5 rounded-full bg-white/20 text-xs">
{counts[key]} {counts[key]}
</span> </span>
</TabsTrigger> </TabsTrigger>
) ))}
)}
</TabsList> </TabsList>
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map( {(
(marketType) => ( Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
).map((marketType) => (
<TabsContent key={marketType} value={marketType} className="mt-6"> <TabsContent key={marketType} value={marketType} className="mt-6">
<div className="space-y-4"> <div className="space-y-4">
{/* Refresh button */} {/* Refresh button */}
@ -733,7 +883,8 @@ export default function HistoryPage() {
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" /> <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"> <p className="text-gray-500 dark:text-gray-400">
{t.history.noReportsFor} {MARKET_LABELS[marketType].label} {t.history.noReportsFor}{" "}
{MARKET_LABELS[marketType].label}
</p> </p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2"> <p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
{t.history.afterAnalysisSave} {t.history.afterAnalysisSave}
@ -773,15 +924,20 @@ export default function HistoryPage() {
{format( {format(
new Date(report.saved_at), new Date(report.saved_at),
"yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm",
{ locale: zhTW } { locale: zhTW },
)} )}
</p> </p>
{(() => { {(() => {
const decision = extractDecisionFromReport(report); const decision =
extractDecisionFromReport(report);
return ( return (
<p className="text-sm mt-2"> <p className="text-sm mt-2">
<span className="font-medium">{t.history.decision}</span> <span className="font-medium">
<span className={`ml-1 font-semibold ${decision.color}`}> {t.history.decision}
</span>
<span
className={`ml-1 font-semibold ${decision.color}`}
>
{decision.action} {decision.action}
</span> </span>
</p> </p>
@ -833,8 +989,7 @@ export default function HistoryPage() {
)} )}
</div> </div>
</TabsContent> </TabsContent>
) ))}
)}
</Tabs> </Tabs>
</div> </div>
</div> </div>
@ -845,7 +1000,8 @@ export default function HistoryPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>{t.history.confirmDeleteTitle}</DialogTitle> <DialogTitle>{t.history.confirmDeleteTitle}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.history.confirmDeleteDesc} <strong>{reportToDelete?.ticker}</strong> {t.history.on}{" "} {t.history.confirmDeleteDesc}{" "}
<strong>{reportToDelete?.ticker}</strong> {t.history.on}{" "}
<strong>{reportToDelete?.analysis_date}</strong>? <strong>{reportToDelete?.analysis_date}</strong>?
<br /> <br />
<span className="text-red-500">{t.history.cannotUndo}</span> <span className="text-red-500">{t.history.cannotUndo}</span>