import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import { apiClient } from "../api/client"; import { conflictResolver, createPullService, createPushService, createSyncManager, type SyncManagerEvent, type SyncQueueState, type SyncResult, SyncStatus, syncQueue, } from "../sync"; import type { ServerCard, ServerDeck, ServerReviewLog, SyncPullResult, } from "../sync/pull"; import type { SyncPushData, SyncPushResult } from "../sync/push"; export interface SyncState { isOnline: boolean; isSyncing: boolean; pendingCount: number; lastSyncAt: Date | null; lastError: string | null; status: SyncQueueState["status"]; } export interface SyncActions { sync: () => Promise; } export type SyncContextValue = SyncState & SyncActions; const SyncContext = createContext(null); export interface SyncProviderProps { children: ReactNode; } interface PullResponse { decks: Array< Omit & { createdAt: string; updatedAt: string; deletedAt: string | null; } >; cards: Array< Omit< ServerCard, "due" | "lastReview" | "createdAt" | "updatedAt" | "deletedAt" > & { due: string; lastReview: string | null; createdAt: string; updatedAt: string; deletedAt: string | null; } >; reviewLogs: Array< Omit & { reviewedAt: string; } >; currentSyncVersion: number; } async function pushToServer(data: SyncPushData): Promise { const authHeader = apiClient.getAuthHeader(); if (!authHeader) { throw new Error("Not authenticated"); } const res = await fetch("/api/sync/push", { method: "POST", headers: { ...authHeader, "Content-Type": "application/json", }, body: JSON.stringify(data), }); if (!res.ok) { const errorBody = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(errorBody.error || `Push failed with status ${res.status}`); } return res.json() as Promise; } async function pullFromServer( lastSyncVersion: number, ): Promise { const authHeader = apiClient.getAuthHeader(); if (!authHeader) { throw new Error("Not authenticated"); } const res = await fetch(`/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, { headers: authHeader, }); if (!res.ok) { const errorBody = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(errorBody.error || `Pull failed with status ${res.status}`); } const data = (await res.json()) as PullResponse; return { decks: data.decks.map((d) => ({ ...d, createdAt: new Date(d.createdAt), updatedAt: new Date(d.updatedAt), deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, })), cards: data.cards.map((c) => ({ ...c, due: new Date(c.due), lastReview: c.lastReview ? new Date(c.lastReview) : null, createdAt: new Date(c.createdAt), updatedAt: new Date(c.updatedAt), deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, })), reviewLogs: data.reviewLogs.map((r) => ({ ...r, reviewedAt: new Date(r.reviewedAt), })), currentSyncVersion: data.currentSyncVersion, }; } const pushService = createPushService({ syncQueue, pushToServer, }); const pullService = createPullService({ syncQueue, pullFromServer, }); const syncManager = createSyncManager({ syncQueue, pushService, pullService, conflictResolver, }); export function SyncProvider({ children }: SyncProviderProps) { const [isOnline, setIsOnline] = useState( typeof navigator !== "undefined" ? navigator.onLine : true, ); const [isSyncing, setIsSyncing] = useState(false); const [pendingCount, setPendingCount] = useState(0); const [lastSyncAt, setLastSyncAt] = useState(null); const [lastError, setLastError] = useState(null); const [status, setStatus] = useState( SyncStatus.Idle, ); useEffect(() => { syncManager.start(); const unsubscribeManager = syncManager.subscribe( (event: SyncManagerEvent) => { switch (event.type) { case "online": setIsOnline(true); break; case "offline": setIsOnline(false); break; case "sync_start": setIsSyncing(true); setLastError(null); setStatus(SyncStatus.Syncing); break; case "sync_complete": setIsSyncing(false); setLastSyncAt(new Date()); setStatus(SyncStatus.Idle); break; case "sync_error": setIsSyncing(false); setLastError(event.error); setStatus(SyncStatus.Error); break; } }, ); const unsubscribeQueue = syncQueue.subscribe((state: SyncQueueState) => { setPendingCount(state.pendingCount); if (state.lastSyncAt) { setLastSyncAt(state.lastSyncAt); } if (state.lastError) { setLastError(state.lastError); } setStatus(state.status); }); // Initialize state from queue syncQueue.getState().then((state) => { setPendingCount(state.pendingCount); setLastSyncAt(state.lastSyncAt); setLastError(state.lastError); setStatus(state.status); }); return () => { unsubscribeManager(); unsubscribeQueue(); syncManager.stop(); }; }, []); const sync = useCallback(async () => { return syncManager.sync(); }, []); const value = useMemo( () => ({ isOnline, isSyncing, pendingCount, lastSyncAt, lastError, status, sync, }), [isOnline, isSyncing, pendingCount, lastSyncAt, lastError, status, sync], ); return {children}; } export function useSync(): SyncContextValue { const context = useContext(SyncContext); if (!context) { throw new Error("useSync must be used within a SyncProvider"); } return context; }