aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/stores/sync.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/stores/sync.tsx')
-rw-r--r--src/client/stores/sync.tsx260
1 files changed, 260 insertions, 0 deletions
diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx
new file mode 100644
index 0000000..29c6c4f
--- /dev/null
+++ b/src/client/stores/sync.tsx
@@ -0,0 +1,260 @@
+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<SyncResult>;
+}
+
+export type SyncContextValue = SyncState & SyncActions;
+
+const SyncContext = createContext<SyncContextValue | null>(null);
+
+export interface SyncProviderProps {
+ children: ReactNode;
+}
+
+interface PullResponse {
+ decks: Array<
+ Omit<ServerDeck, "createdAt" | "updatedAt" | "deletedAt"> & {
+ 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<ServerReviewLog, "reviewedAt"> & {
+ reviewedAt: string;
+ }
+ >;
+ currentSyncVersion: number;
+}
+
+async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
+ 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<SyncPushResult>;
+}
+
+async function pullFromServer(
+ lastSyncVersion: number,
+): Promise<SyncPullResult> {
+ 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<Date | null>(null);
+ const [lastError, setLastError] = useState<string | null>(null);
+ const [status, setStatus] = useState<SyncQueueState["status"]>(
+ 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<SyncContextValue>(
+ () => ({
+ isOnline,
+ isSyncing,
+ pendingCount,
+ lastSyncAt,
+ lastError,
+ status,
+ sync,
+ }),
+ [isOnline, isSyncing, pendingCount, lastSyncAt, lastError, status, sync],
+ );
+
+ return <SyncContext.Provider value={value}>{children}</SyncContext.Provider>;
+}
+
+export function useSync(): SyncContextValue {
+ const context = useContext(SyncContext);
+ if (!context) {
+ throw new Error("useSync must be used within a SyncProvider");
+ }
+ return context;
+}