From 0c042ac89fc0822fcbe09c48702857faa5494ae1 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 23:34:03 +0900 Subject: feat(client): add sync status indicator component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SyncStatusIndicator component to display current sync state in the UI header. The component shows online/offline status, syncing progress, pending changes count, and sync errors. - Create SyncProvider context to wrap SyncManager for React components - Add SyncStatusIndicator component with visual status indicators - Integrate indicator into HomePage header - Add comprehensive tests for SyncStatusIndicator and SyncProvider - Update existing tests to include SyncProvider wrapper 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/stores/index.ts | 8 ++ src/client/stores/sync.test.tsx | 209 ++++++++++++++++++++++++++++++++ src/client/stores/sync.tsx | 260 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 src/client/stores/sync.test.tsx create mode 100644 src/client/stores/sync.tsx (limited to 'src/client/stores') diff --git a/src/client/stores/index.ts b/src/client/stores/index.ts index 7117bd6..c7f6241 100644 --- a/src/client/stores/index.ts +++ b/src/client/stores/index.ts @@ -5,3 +5,11 @@ export type { AuthState, } from "./auth"; export { ApiClientError, AuthProvider, useAuth } from "./auth"; + +export type { + SyncActions, + SyncContextValue, + SyncProviderProps, + SyncState, +} from "./sync"; +export { SyncProvider, useSync } from "./sync"; diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx new file mode 100644 index 0000000..9c4e5c2 --- /dev/null +++ b/src/client/stores/sync.test.tsx @@ -0,0 +1,209 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "../db/index"; +import { SyncProvider, useSync } from "./sync"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock apiClient +vi.mock("../api/client", () => ({ + apiClient: { + getAuthHeader: vi.fn(() => ({ Authorization: "Bearer token" })), + }, +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("useSync", () => { + beforeEach(async () => { + vi.clearAllMocks(); + localStorage.clear(); + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + + // Default mock for fetch + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + currentSyncVersion: 0, + }), + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + localStorage.clear(); + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + it("throws error when used outside SyncProvider", () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + renderHook(() => useSync()); + }).toThrow("useSync must be used within a SyncProvider"); + + consoleSpy.mockRestore(); + }); + + it("returns initial state", async () => { + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isOnline).toBe(true); + expect(result.current.isSyncing).toBe(false); + expect(result.current.pendingCount).toBe(0); + expect(result.current.lastSyncAt).toBeNull(); + expect(result.current.lastError).toBeNull(); + expect(result.current.status).toBe("idle"); + }); + }); + + it("provides sync function", async () => { + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(typeof result.current.sync).toBe("function"); + }); + }); + + it("updates isSyncing during sync", async () => { + // Make the sync take some time + mockFetch.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: true, + json: async () => ({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + currentSyncVersion: 0, + }), + }), + 50, + ), + ), + ); + + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSyncing).toBe(false); + }); + + // Start sync + let syncPromise: Promise; + act(() => { + syncPromise = result.current.sync(); + }); + + // Check that isSyncing becomes true + await waitFor(() => { + expect(result.current.isSyncing).toBe(true); + }); + + // Wait for sync to complete + await act(async () => { + await syncPromise; + }); + + expect(result.current.isSyncing).toBe(false); + }); + + it("updates lastSyncAt after successful sync", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + currentSyncVersion: 1, + }), + }); + + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(result.current.lastSyncAt).toBeNull(); + }); + + await act(async () => { + await result.current.sync(); + }); + + await waitFor(() => { + expect(result.current.lastSyncAt).not.toBeNull(); + }); + }); + + it("updates lastError on sync failure", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: "Server error" }), + }); + + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(result.current.lastError).toBeNull(); + }); + + await act(async () => { + await result.current.sync(); + }); + + await waitFor(() => { + expect(result.current.lastError).toBe("Server error"); + expect(result.current.status).toBe("error"); + }); + }); + + it("responds to online/offline events", async () => { + const { result } = renderHook(() => useSync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isOnline).toBe(true); + }); + + // Simulate going offline + act(() => { + window.dispatchEvent(new Event("offline")); + }); + + await waitFor(() => { + expect(result.current.isOnline).toBe(false); + }); + + // Simulate going online + act(() => { + window.dispatchEvent(new Event("online")); + }); + + await waitFor(() => { + expect(result.current.isOnline).toBe(true); + }); + }); +}); 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; +} + +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; +} -- cgit v1.2.3-70-g09d2