This commit is contained in:
parent
a45bd56ad0
commit
c305ea5d3b
|
|
@ -0,0 +1,87 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
TradingAgentsX is a multi-agent AI trading analysis system that simulates real-world investment firm operations. It uses LangGraph to orchestrate 12 specialized AI agents (analysts, researchers, traders, risk managers) that collaborate through structured debate workflows to generate trading decisions.
|
||||||
|
|
||||||
|
**Stack**: Python 3.10+ backend (FastAPI) + Next.js 16 frontend (React 19, TypeScript)
|
||||||
|
|
||||||
|
## Build & Run Commands
|
||||||
|
|
||||||
|
### Backend (FastAPI)
|
||||||
|
```bash
|
||||||
|
pip install -e . # Install package in editable mode
|
||||||
|
pip install -r backend/requirements.txt # Install backend dependencies
|
||||||
|
python -m backend # Run server (default: localhost:8000)
|
||||||
|
python -m backend --host 0.0.0.0 --port 8000 --reload true # With options
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Next.js)
|
||||||
|
```bash
|
||||||
|
bun install --cwd frontend # Install dependencies
|
||||||
|
bun run --cwd frontend dev # Development server (localhost:3000)
|
||||||
|
bun run --cwd frontend build # Production build
|
||||||
|
bun run --cwd frontend lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```bash
|
||||||
|
tradingagents <commands> # After pip install -e .
|
||||||
|
python main.py # Standalone analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Agent System (`tradingagents/`)
|
||||||
|
- **12 AI agents** organized into teams using LangGraph orchestration
|
||||||
|
- `agents/analysts/` - Market, News, Social Media, Fundamentals analysts
|
||||||
|
- `agents/researchers/` - Bull & Bear case researchers + Research Manager
|
||||||
|
- `agents/risk_mgmt/` - Aggressive, Conservative, Neutral debaters + Risk Manager
|
||||||
|
- `agents/trader/` - Final trading decision aggregator
|
||||||
|
- `graph/trading_graph.py` - Main `TradingAgentsXGraph` class orchestrating the workflow
|
||||||
|
- `dataflows/` - Data vendor integrations (Yahoo Finance, Alpha Vantage, FinMind, Google News, Reddit)
|
||||||
|
|
||||||
|
### Backend (`backend/`)
|
||||||
|
- `app/main.py` - FastAPI app with security middleware (rate limiting 30/min, security headers)
|
||||||
|
- `app/api/routes.py` - `/api/analyze`, `/api/task/{id}`, `/api/chat`, `/api/download`
|
||||||
|
- `app/api/auth.py` - Google OAuth flow
|
||||||
|
- `app/services/trading_service.py` - Orchestrates agent graph execution
|
||||||
|
- `app/services/task_manager.py` - Async task lifecycle
|
||||||
|
- `app/services/pdf_generator.py` - Report generation (complex, 61KB)
|
||||||
|
|
||||||
|
### Frontend (`frontend/`)
|
||||||
|
- `app/` - Next.js App Router pages (analysis, history, auth)
|
||||||
|
- `components/AgentFlowDiagram.tsx` - 12-agent visualization
|
||||||
|
- `lib/crypto.ts` - AES-GCM encryption for API keys (BYOK model)
|
||||||
|
- `lib/reports-db.ts` - IndexedDB wrapper for local report storage
|
||||||
|
- `contexts/` - AuthContext, LanguageContext
|
||||||
|
|
||||||
|
## Key Entry Points
|
||||||
|
|
||||||
|
- **Backend**: `python -m backend` → `backend/__main__.py` → `backend/app/main.py`
|
||||||
|
- **Frontend**: `bun run dev` → `frontend/app/layout.tsx` → `frontend/app/page.tsx`
|
||||||
|
- **Core Logic**: `TradingAgentsXGraph.propagate(ticker, date)` in `tradingagents/graph/trading_graph.py`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required in `.env` (see `.env.example`):
|
||||||
|
- LLM API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, etc.
|
||||||
|
- Data APIs: `ALPHA_VANTAGE_API_KEY`, `FINMIND_API_KEY`
|
||||||
|
- Auth: `JWT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||||
|
- Config: `NEXT_PUBLIC_API_URL`, `CORS_ORIGINS`, `FRONTEND_URL`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- **Agent Framework**: LangGraph 0.4.8+, LangChain 0.1.0+
|
||||||
|
- **LLM Providers**: langchain-openai, langchain-anthropic, langchain-google-genai
|
||||||
|
- **Data**: yfinance, pandas, polars, ChromaDB (vector store)
|
||||||
|
- **Backend**: FastAPI, SQLAlchemy 2.0+, Redis, asyncpg
|
||||||
|
- **Frontend**: Next.js 16, React 19, Tailwind CSS 4, shadcn/ui, Dexie.js (IndexedDB)
|
||||||
|
|
@ -151,16 +151,38 @@ async def update_settings(
|
||||||
@router.get("/reports", response_model=List[ReportResponse])
|
@router.get("/reports", response_model=List[ReportResponse])
|
||||||
async def get_reports(
|
async def get_reports(
|
||||||
user: User = Depends(get_current_user_required),
|
user: User = Depends(get_current_user_required),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db),
|
||||||
|
market_type: Optional[str] = None,
|
||||||
|
language: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
):
|
):
|
||||||
"""Get all user's reports"""
|
"""Get user's reports with optional filtering and pagination
|
||||||
result = await db.execute(
|
|
||||||
select(Report)
|
Args:
|
||||||
.where(Report.user_id == user.id)
|
market_type: Filter by market type (us, twse, tpex)
|
||||||
.order_by(Report.created_at.desc())
|
language: Filter by language (en, zh-TW)
|
||||||
)
|
limit: Maximum number of reports to return (default 100, max 500)
|
||||||
|
offset: Number of reports to skip for pagination
|
||||||
|
"""
|
||||||
|
# Cap limit at 500 to prevent memory issues
|
||||||
|
limit = min(limit, 500)
|
||||||
|
|
||||||
|
# Build query with filters
|
||||||
|
query = select(Report).where(Report.user_id == user.id)
|
||||||
|
|
||||||
|
if market_type:
|
||||||
|
query = query.where(Report.market_type == market_type)
|
||||||
|
|
||||||
|
if language:
|
||||||
|
query = query.where(Report.language == language)
|
||||||
|
|
||||||
|
# Order by created_at DESC and apply pagination
|
||||||
|
query = query.order_by(Report.created_at.desc()).offset(offset).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
reports = result.scalars().all()
|
reports = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ReportResponse(
|
ReportResponse(
|
||||||
id=str(r.id),
|
id=str(r.id),
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,12 @@ async def init_db():
|
||||||
# Add indexes to optimize queries
|
# Add indexes to optimize queries
|
||||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_id ON reports (user_id);"))
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_id ON reports (user_id);"))
|
||||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_created_at ON reports (created_at);"))
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_created_at ON reports (created_at);"))
|
||||||
|
# Add composite index for common query pattern (user_id + created_at DESC)
|
||||||
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_created ON reports (user_id, created_at DESC);"))
|
||||||
|
# Add index for language filtering
|
||||||
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_language ON reports (language);"))
|
||||||
|
# Add composite index for user + market_type + language queries
|
||||||
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_market_lang ON reports (user_id, market_type, language);"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Skipping manual migration (might be SQLite or syntax not supported): {e}")
|
print(f"Skipping manual migration (might be SQLite or syntax not supported): {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ import {
|
||||||
import {
|
import {
|
||||||
getReportsByMarketType,
|
getReportsByMarketType,
|
||||||
deleteReport,
|
deleteReport,
|
||||||
getReportCountByMarketType,
|
|
||||||
deleteReports,
|
deleteReports,
|
||||||
getAllReports,
|
getAllReports,
|
||||||
|
bulkSaveReports,
|
||||||
type SavedReport,
|
type SavedReport,
|
||||||
} from "@/lib/reports-db";
|
} from "@/lib/reports-db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -406,6 +406,9 @@ export default function HistoryPage() {
|
||||||
);
|
);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
// Auto-sync tracking ref
|
// Auto-sync tracking ref
|
||||||
const hasAutoSyncedRef = useRef(false);
|
const hasAutoSyncedRef = useRef(false);
|
||||||
const cloudReportsPromiseRef = useRef<Promise<any[]> | null>(null);
|
const cloudReportsPromiseRef = useRef<Promise<any[]> | null>(null);
|
||||||
|
|
@ -430,76 +433,66 @@ export default function HistoryPage() {
|
||||||
loadCounts();
|
loadCounts();
|
||||||
}, [isAuthenticated, locale]);
|
}, [isAuthenticated, locale]);
|
||||||
|
|
||||||
// Auto-sync local reports to cloud when page loads (if authenticated)
|
// Bidirectional sync: upload local to cloud AND download cloud to local
|
||||||
useEffect(() => {
|
const performBidirectionalSync = async (isInitialSync = false) => {
|
||||||
const autoSync = async () => {
|
if (!isAuthenticated || !isCloudSyncEnabled()) {
|
||||||
// Only sync once per session, and only if authenticated
|
return;
|
||||||
if (
|
}
|
||||||
hasAutoSyncedRef.current ||
|
|
||||||
!isAuthenticated ||
|
// For initial sync, only run once per session
|
||||||
!isCloudSyncEnabled()
|
if (isInitialSync && hasAutoSyncedRef.current) {
|
||||||
) {
|
return;
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAutoSyncedRef.current = true;
|
// 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];
|
||||||
|
|
||||||
try {
|
// Get cloud reports
|
||||||
// First auto-clean local duplicates that might exist from older flawed versions
|
const cloudReports = await fetchCloudReportsCached(true); // Force refresh
|
||||||
try {
|
const cloudKeys = new Set(cloudReports.map((r) => getReportSignature(r)));
|
||||||
const allLocal = await getAllReports();
|
const localKeys = new Set(allLocal.map((r) => getReportSignature(r)));
|
||||||
const seenSignatures = new Set<string>();
|
|
||||||
const idsToDelete: number[] = [];
|
|
||||||
|
|
||||||
for (const report of allLocal) {
|
// === UPLOAD: Local -> Cloud ===
|
||||||
const signature = getReportSignature(report as any);
|
const toUpload = allLocal.filter(
|
||||||
if (seenSignatures.has(signature)) {
|
(r) => !cloudKeys.has(getReportSignature(r)),
|
||||||
if (report.id) idsToDelete.push(report.id);
|
);
|
||||||
} else {
|
|
||||||
seenSignatures.add(signature);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idsToDelete.length > 0) {
|
if (toUpload.length > 0) {
|
||||||
console.log(`🧹 Found ${idsToDelete.length} duplicate local reports from flawed storage, cleaning them up...`);
|
console.log(`☁️ Sync: Uploading ${toUpload.length} local reports to cloud...`);
|
||||||
await deleteReports(idsToDelete);
|
let uploadSuccess = 0;
|
||||||
}
|
|
||||||
} 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];
|
|
||||||
|
|
||||||
if (allLocal.length === 0) return;
|
|
||||||
|
|
||||||
// Get cloud reports to check for duplicates
|
|
||||||
const cloudReports = await fetchCloudReportsCached();
|
|
||||||
const cloudKeys = new Set(
|
|
||||||
cloudReports.map((r) => getReportSignature(r)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find local-only reports to upload
|
|
||||||
const toUpload = allLocal.filter(
|
|
||||||
(r) => !cloudKeys.has(getReportSignature(r)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (toUpload.length === 0) {
|
|
||||||
console.log("☁️ Auto-sync: All reports already in cloud");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upload each report silently
|
|
||||||
let success = 0;
|
|
||||||
for (const report of toUpload) {
|
for (const report of toUpload) {
|
||||||
try {
|
try {
|
||||||
const cloudId = await saveCloudReport({
|
const cloudId = await saveCloudReport({
|
||||||
|
|
@ -507,31 +500,107 @@ export default function HistoryPage() {
|
||||||
market_type: report.market_type,
|
market_type: report.market_type,
|
||||||
analysis_date: report.analysis_date,
|
analysis_date: report.analysis_date,
|
||||||
result: report.result,
|
result: report.result,
|
||||||
|
language: report.language || detectReportLanguage(report.result?.reports),
|
||||||
});
|
});
|
||||||
if (cloudId) success++;
|
if (cloudId) uploadSuccess++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently continue on error
|
// Silently continue on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (uploadSuccess > 0) {
|
||||||
if (success > 0) {
|
console.log(`☁️ Sync: Uploaded ${uploadSuccess} reports to cloud`);
|
||||||
console.log(`☁️ Auto-sync: Successfully uploaded ${success} reports`);
|
|
||||||
// Reload to show updated data
|
|
||||||
await fetchCloudReportsCached(true);
|
|
||||||
await loadReports();
|
|
||||||
await loadCounts();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.error("☁️ Auto-sync failed:", error);
|
|
||||||
|
// === 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
autoSync();
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const loadReports = async () => {
|
const loadReports = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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
|
// Always load local IndexedDB reports first
|
||||||
const localData = await getReportsByMarketType(activeTab);
|
const localData = await getReportsByMarketType(activeTab);
|
||||||
|
|
||||||
|
|
@ -574,18 +643,25 @@ export default function HistoryPage() {
|
||||||
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(),
|
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
setReports(merged);
|
// Filter by current UI language before display
|
||||||
|
setReports(filterByLang(merged));
|
||||||
setIsCloudData(true);
|
setIsCloudData(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setReports(localData);
|
// Filter local data by language before display
|
||||||
|
setReports(filterByLang(localData));
|
||||||
setIsCloudData(false);
|
setIsCloudData(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load reports:", error);
|
console.error("Failed to load reports:", error);
|
||||||
const data = await getReportsByMarketType(activeTab);
|
const data = await getReportsByMarketType(activeTab);
|
||||||
setReports(data as SavedReport[]);
|
// 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);
|
setIsCloudData(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -594,9 +670,14 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
const loadCounts = async () => {
|
const loadCounts = async () => {
|
||||||
try {
|
try {
|
||||||
// Helper to filter reports by language
|
// Helper to filter reports by language (matches current UI locale)
|
||||||
const filterByLanguage = (reports: SavedReport[]) => {
|
const filterByLanguage = (reports: SavedReport[]) => {
|
||||||
return reports;
|
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()) {
|
if (isAuthenticated && isCloudSyncEnabled()) {
|
||||||
|
|
@ -672,6 +753,20 @@ export default function HistoryPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
const handleViewReport = (report: SavedReport) => {
|
||||||
// Set the context with the saved report data
|
// Set the context with the saved report data
|
||||||
setAnalysisResult(report.result);
|
setAnalysisResult(report.result);
|
||||||
|
|
@ -701,17 +796,26 @@ 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 exact matching local report by signature
|
// Use strict matching: ticker + date + market_type + language
|
||||||
try {
|
try {
|
||||||
const localReports = await getReportsByMarketType(
|
const localReports = await getReportsByMarketType(
|
||||||
reportToDelete.market_type,
|
reportToDelete.market_type,
|
||||||
);
|
);
|
||||||
const targetSignature = getReportSignature(reportToDelete);
|
// Get language of report to delete (use stored or detect)
|
||||||
const matchingLocal = localReports.find(
|
const targetLang = reportToDelete.language || detectReportLanguage(reportToDelete.result?.reports);
|
||||||
(r) => getReportSignature(r) === targetSignature
|
|
||||||
);
|
// 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) {
|
if (matchingLocal && matchingLocal.id) {
|
||||||
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id);
|
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id, "language:", targetLang);
|
||||||
await deleteReport(matchingLocal.id);
|
await deleteReport(matchingLocal.id);
|
||||||
}
|
}
|
||||||
} catch (localError) {
|
} catch (localError) {
|
||||||
|
|
@ -900,19 +1004,19 @@ export default function HistoryPage() {
|
||||||
).map((marketType) => (
|
).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/Sync button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadReports}
|
onClick={handleRefresh}
|
||||||
disabled={loading}
|
disabled={loading || syncing}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
className={`h-4 w-4 ${loading || syncing ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
{t.history.refresh}
|
{syncing ? t.history.syncing : t.history.refresh}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,7 @@ export const en = {
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
deleting: "Deleting...",
|
deleting: "Deleting...",
|
||||||
confirmDeleteBtn: "Confirm Delete",
|
confirmDeleteBtn: "Confirm Delete",
|
||||||
|
syncing: "Syncing...",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,7 @@ export const zhTW = {
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
deleting: "刪除中...",
|
deleting: "刪除中...",
|
||||||
confirmDeleteBtn: "確認刪除",
|
confirmDeleteBtn: "確認刪除",
|
||||||
|
syncing: "同步中...",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,51 @@ export async function checkDuplicateReport(
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a report exists by ticker, date, market type, and language
|
||||||
|
* Used for bidirectional sync to prevent duplicates
|
||||||
|
*/
|
||||||
|
export async function findExistingReport(
|
||||||
|
ticker: string,
|
||||||
|
analysis_date: string,
|
||||||
|
market_type: "us" | "twse" | "tpex",
|
||||||
|
language?: "en" | "zh-TW",
|
||||||
|
): Promise<SavedReport | undefined> {
|
||||||
|
return await db.reports
|
||||||
|
.where("ticker")
|
||||||
|
.equals(ticker)
|
||||||
|
.and(
|
||||||
|
(report) =>
|
||||||
|
report.analysis_date === analysis_date &&
|
||||||
|
report.market_type === market_type &&
|
||||||
|
report.language === language
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk save reports to the database (for syncing from cloud)
|
||||||
|
* Skips reports that already exist locally
|
||||||
|
*/
|
||||||
|
export async function bulkSaveReports(
|
||||||
|
reports: Omit<SavedReport, "id">[]
|
||||||
|
): Promise<number> {
|
||||||
|
let savedCount = 0;
|
||||||
|
for (const report of reports) {
|
||||||
|
const existing = await findExistingReport(
|
||||||
|
report.ticker,
|
||||||
|
report.analysis_date,
|
||||||
|
report.market_type,
|
||||||
|
report.language
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
await db.reports.add(report as SavedReport);
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedCount;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all reports from the database (for logout)
|
* Clear all reports from the database (for logout)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,33 @@ export async function saveCloudSettings(settings: ApiSettings): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all reports from cloud
|
* Options for fetching cloud reports
|
||||||
*/
|
*/
|
||||||
export async function getCloudReports(): Promise<CloudReport[]> {
|
interface GetCloudReportsOptions {
|
||||||
|
market_type?: "us" | "twse" | "tpex";
|
||||||
|
language?: "en" | "zh-TW";
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch reports from cloud with optional filtering and pagination
|
||||||
|
*/
|
||||||
|
export async function getCloudReports(options?: GetCloudReportsOptions): Promise<CloudReport[]> {
|
||||||
if (!isCloudSyncEnabled()) return [];
|
if (!isCloudSyncEnabled()) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/user/reports`, {
|
// Build query params
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.market_type) params.set("market_type", options.market_type);
|
||||||
|
if (options?.language) params.set("language", options.language);
|
||||||
|
if (options?.limit) params.set("limit", options.limit.toString());
|
||||||
|
if (options?.offset) params.set("offset", options.offset.toString());
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = `${API_BASE}/api/user/reports${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue