diff --git a/frontend/app/analysis/page.tsx b/frontend/app/analysis/page.tsx index 353206e3..323b32f3 100644 --- a/frontend/app/analysis/page.tsx +++ b/frontend/app/analysis/page.tsx @@ -63,6 +63,10 @@ export default function AnalysisPage() { }); if (cloudId) { console.log("â˜ī¸ Auto-saved report to cloud"); + } else { + // Cloud sync failed - mark for retry but don't fail the auto-save + // The report is already safely stored in local IndexedDB + console.warn("âš ī¸ Cloud sync failed, but report saved locally. Will retry later."); } } // Note: Redis cleanup is handled immediately when analysis completes diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 1f3c106f..47291b09 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -7,6 +7,7 @@ import { AnalysisProvider } from "@/context/AnalysisContext"; import { ThemeProvider } from "@/components/theme/ThemeProvider"; import { AuthProvider } from "@/contexts/auth-context"; import { LanguageProvider } from "@/contexts/LanguageContext"; +import { SyncInitializer } from "@/components/providers/SyncInitializer"; const inter = Inter({ subsets: ["latin"] }); @@ -118,6 +119,7 @@ export default function RootLayout({ +
diff --git a/frontend/components/providers/SyncInitializer.tsx b/frontend/components/providers/SyncInitializer.tsx new file mode 100644 index 00000000..a82be0f3 --- /dev/null +++ b/frontend/components/providers/SyncInitializer.tsx @@ -0,0 +1,27 @@ +/** + * Sync Initializer - Starts cloud sync retry loop on app startup + * This client component ensures that failed cloud syncs are automatically retried + */ +"use client"; + +import { useEffect } from "react"; +import { startRetryLoop, stopRetryLoop } from "@/lib/sync-retry"; +import { isCloudSyncEnabled } from "@/lib/user-api"; + +export function SyncInitializer() { + useEffect(() => { + // Only start retry loop if user is authenticated + if (isCloudSyncEnabled()) { + console.log("🔄 Starting cloud sync retry service"); + startRetryLoop(); + + // Cleanup on unmount + return () => { + console.log("âšī¸ Stopping cloud sync retry service"); + stopRetryLoop(); + }; + } + }, []); + + return null; // This component doesn't render anything +} diff --git a/frontend/lib/sync-retry.ts b/frontend/lib/sync-retry.ts new file mode 100644 index 00000000..58401226 --- /dev/null +++ b/frontend/lib/sync-retry.ts @@ -0,0 +1,220 @@ +/** + * Cloud sync retry service + * Handles retrying failed cloud syncs for reports stored in local IndexedDB + */ + +import { getAllReports, saveReport } from "./reports-db"; +import { saveCloudReport, isCloudSyncEnabled } from "./user-api"; +import type { SavedReport } from "./reports-db"; + +// Retry configuration +const RETRY_INTERVAL = 30000; // 30 seconds between retry attempts +const MAX_RETRIES = 5; // Give up after 5 failed attempts +const RETRY_BACKOFF = 1.5; // Exponential backoff multiplier + +interface RetryRecord { + ticker: string; + analysis_date: string; + market_type: "us" | "twse" | "tpex"; + language?: "en" | "zh-TW"; + attempts: number; + last_attempt: number; +} + +// Track retry attempts in memory +const retryMap = new Map(); + +/** + * Generate a unique key for a report for retry tracking + */ +function getReportKey( + ticker: string, + analysis_date: string, + market_type: string, + language?: string +): string { + return `${ticker}|${analysis_date}|${market_type}|${language || "zh-TW"}`; +} + +/** + * Retry a single report's cloud sync + */ +async function retrySingleReport(report: SavedReport): Promise { + if (!isCloudSyncEnabled()) { + console.log("Cloud sync not enabled, skipping retry"); + return false; + } + + const key = getReportKey( + report.ticker, + report.analysis_date, + report.market_type, + report.language + ); + + // Check retry attempts + const retryRecord = retryMap.get(key); + if (retryRecord && retryRecord.attempts >= MAX_RETRIES) { + console.warn( + `âš ī¸ [${report.ticker}] Max retries exceeded, giving up on cloud sync` + ); + retryMap.delete(key); + return false; + } + + try { + console.log( + `🔄 [${report.ticker}] Retrying cloud sync (attempt ${(retryRecord?.attempts || 0) + 1}/${MAX_RETRIES})` + ); + + const cloudId = await saveCloudReport({ + ticker: report.ticker, + market_type: report.market_type, + analysis_date: report.analysis_date, + result: report.result, + language: report.language, + }); + + if (cloudId) { + console.log(`✅ [${report.ticker}] Cloud sync successful, clearing retry record`); + retryMap.delete(key); + return true; + } else { + // Still failed, increment retry count + if (!retryRecord) { + retryMap.set(key, { + ticker: report.ticker, + analysis_date: report.analysis_date, + market_type: report.market_type, + language: report.language, + attempts: 1, + last_attempt: Date.now(), + }); + } else { + retryRecord.attempts++; + retryRecord.last_attempt = Date.now(); + } + return false; + } + } catch (error) { + console.error(`❌ [${report.ticker}] Cloud sync retry failed:`, error); + + // Increment retry count + if (!retryRecord) { + retryMap.set(key, { + ticker: report.ticker, + analysis_date: report.analysis_date, + market_type: report.market_type, + language: report.language, + attempts: 1, + last_attempt: Date.now(), + }); + } else { + retryRecord.attempts++; + retryRecord.last_attempt = Date.now(); + } + return false; + } +} + +/** + * Attempt to sync all reports with pending_sync flag + */ +export async function retryPendingSyncs(): Promise<{ + successful: number; + failed: number; +}> { + if (!isCloudSyncEnabled()) { + console.log("Cloud sync not enabled, skipping retry"); + return { successful: 0, failed: 0 }; + } + + try { + const allReports = await getAllReports(); + let successful = 0; + let failed = 0; + + // Try to sync reports (we'll assume any local report without a cloud_id needs syncing) + for (const report of allReports) { + if (!report.cloud_id && report.pending_sync) { + const synced = await retrySingleReport(report); + if (synced) { + successful++; + // Update the report to clear pending_sync flag + // Note: This would require an updateReport function in reports-db.ts + } else { + failed++; + } + } + } + + if (successful > 0 || failed > 0) { + console.log( + `📊 Cloud sync retry summary: ${successful} successful, ${failed} failed` + ); + } + + return { successful, failed }; + } catch (error) { + console.error("Error retrying pending syncs:", error); + return { successful: 0, failed: 0 }; + } +} + +/** + * Get the number of pending syncs + */ +export function getPendingSyncCount(): number { + return retryMap.size; +} + +/** + * Mark a report as needing retry + */ +export function markForRetry( + ticker: string, + analysis_date: string, + market_type: "us" | "twse" | "tpex", + language?: "en" | "zh-TW" +): void { + const key = getReportKey(ticker, analysis_date, market_type, language); + if (!retryMap.has(key)) { + retryMap.set(key, { + ticker, + analysis_date, + market_type, + language, + attempts: 0, + last_attempt: 0, + }); + console.log(`📌 [${ticker}] Marked for cloud sync retry`); + } +} + +/** + * Start automatic retry loop (should be called once on app startup) + */ +let retryIntervalId: NodeJS.Timeout | null = null; + +export function startRetryLoop(): void { + if (retryIntervalId) { + console.warn("Retry loop already started"); + return; + } + + retryIntervalId = setInterval(async () => { + if (isCloudSyncEnabled() && retryMap.size > 0) { + await retryPendingSyncs(); + } + }, RETRY_INTERVAL); + + console.log("🔄 Cloud sync retry loop started"); +} + +export function stopRetryLoop(): void { + if (retryIntervalId) { + clearInterval(retryIntervalId); + retryIntervalId = null; + console.log("âšī¸ Cloud sync retry loop stopped"); + } +}