diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:34:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:34:03 +0900 |
| commit | 0c042ac89fc0822fcbe09c48702857faa5494ae1 (patch) | |
| tree | ea1f1d180f747613343040d441a07f92b2760840 | |
| parent | ae5a0bb97fbf013417a6962f7e077f0408b2a951 (diff) | |
| download | kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.gz kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.zst kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.zip | |
feat(client): add sync status indicator component
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 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/App.test.tsx | 7 | ||||
| -rw-r--r-- | src/client/components/SyncStatusIndicator.test.tsx | 160 | ||||
| -rw-r--r-- | src/client/components/SyncStatusIndicator.tsx | 82 | ||||
| -rw-r--r-- | src/client/components/index.ts | 1 | ||||
| -rw-r--r-- | src/client/main.tsx | 6 | ||||
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 7 | ||||
| -rw-r--r-- | src/client/pages/HomePage.tsx | 10 | ||||
| -rw-r--r-- | src/client/stores/index.ts | 8 | ||||
| -rw-r--r-- | src/client/stores/sync.test.tsx | 209 | ||||
| -rw-r--r-- | src/client/stores/sync.tsx | 260 | ||||
| -rw-r--r-- | src/client/sync/conflict.test.ts | 3 | ||||
| -rw-r--r-- | src/client/sync/conflict.ts | 24 | ||||
| -rw-r--r-- | src/client/sync/index.ts | 69 | ||||
| -rw-r--r-- | src/client/sync/manager.test.ts | 23 | ||||
| -rw-r--r-- | src/client/sync/manager.ts | 3 | ||||
| -rw-r--r-- | src/client/sync/pull.test.ts | 10 | ||||
| -rw-r--r-- | src/client/sync/push.test.ts | 6 | ||||
| -rw-r--r-- | src/client/sync/push.ts | 4 | ||||
| -rw-r--r-- | src/client/sync/queue.test.ts | 8 | ||||
| -rw-r--r-- | src/client/sync/queue.ts | 71 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 94 | ||||
| -rw-r--r-- | src/server/routes/sync.test.ts | 20 |
23 files changed, 957 insertions, 130 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 2a1e600..e775ae2 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -163,7 +163,7 @@ Smaller features first to enable early MVP validation. - [x] Add tests ### Sync UI -- [ ] Sync status indicator +- [x] Sync status indicator - [ ] Manual sync button - [ ] Offline mode indicator diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx index c11eb88..8359e67 100644 --- a/src/client/App.test.tsx +++ b/src/client/App.test.tsx @@ -1,13 +1,14 @@ /** * @vitest-environment jsdom */ +import "fake-indexeddb/auto"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { App } from "./App"; import { apiClient } from "./api/client"; -import { AuthProvider } from "./stores"; +import { AuthProvider, SyncProvider } from "./stores"; vi.mock("./api/client", () => ({ apiClient: { @@ -53,7 +54,9 @@ function renderWithRouter(path: string) { return render( <Router hook={hook}> <AuthProvider> - <App /> + <SyncProvider> + <App /> + </SyncProvider> </AuthProvider> </Router>, ); diff --git a/src/client/components/SyncStatusIndicator.test.tsx b/src/client/components/SyncStatusIndicator.test.tsx new file mode 100644 index 0000000..a607e11 --- /dev/null +++ b/src/client/components/SyncStatusIndicator.test.tsx @@ -0,0 +1,160 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SyncStatusIndicator } from "./SyncStatusIndicator"; + +// Mock the useSync hook +const mockUseSync = vi.fn(); +vi.mock("../stores", () => ({ + useSync: () => mockUseSync(), +})); + +// Mock the SyncStatus constant +vi.mock("../sync", () => ({ + SyncStatus: { + Idle: "idle", + Syncing: "syncing", + Error: "error", + }, +})); + +describe("SyncStatusIndicator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("displays 'Synced' when online with no pending changes", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + pendingCount: 0, + lastError: null, + status: "idle", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Synced")).toBeDefined(); + expect(screen.getByTestId("sync-status-indicator")).toBeDefined(); + }); + + it("displays 'Offline' when not online", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + isSyncing: false, + pendingCount: 0, + lastError: null, + status: "idle", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Offline")).toBeDefined(); + }); + + it("displays 'Syncing...' when syncing", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: true, + pendingCount: 0, + lastError: null, + status: "syncing", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Syncing...")).toBeDefined(); + }); + + it("displays pending count when there are pending changes", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + pendingCount: 5, + lastError: null, + status: "idle", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("5 pending")).toBeDefined(); + }); + + it("displays 'Sync error' when there is an error", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + pendingCount: 0, + lastError: "Network error", + status: "error", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Sync error")).toBeDefined(); + }); + + it("shows error message in title when there is an error", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + pendingCount: 0, + lastError: "Network error", + status: "error", + }); + + render(<SyncStatusIndicator />); + + const indicator = screen.getByTestId("sync-status-indicator"); + expect(indicator.getAttribute("title")).toBe("Network error"); + }); + + it("prioritizes offline status over other states", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + isSyncing: true, + pendingCount: 5, + lastError: "Error", + status: "error", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Offline")).toBeDefined(); + }); + + it("prioritizes syncing status over pending and error", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: true, + pendingCount: 5, + lastError: null, + status: "syncing", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Syncing...")).toBeDefined(); + }); + + it("prioritizes error status over pending", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + pendingCount: 5, + lastError: "Network error", + status: "error", + }); + + render(<SyncStatusIndicator />); + + expect(screen.getByText("Sync error")).toBeDefined(); + }); +}); diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx new file mode 100644 index 0000000..23e3ec6 --- /dev/null +++ b/src/client/components/SyncStatusIndicator.tsx @@ -0,0 +1,82 @@ +import { useSync } from "../stores"; +import { SyncStatus } from "../sync"; + +export function SyncStatusIndicator() { + const { isOnline, isSyncing, pendingCount, lastError, status } = useSync(); + + const getStatusText = (): string => { + if (!isOnline) { + return "Offline"; + } + if (isSyncing) { + return "Syncing..."; + } + if (status === SyncStatus.Error && lastError) { + return "Sync error"; + } + if (pendingCount > 0) { + return `${pendingCount} pending`; + } + return "Synced"; + }; + + const getStatusColor = (): string => { + if (!isOnline) { + return "#6c757d"; // gray + } + if (isSyncing) { + return "#007bff"; // blue + } + if (status === SyncStatus.Error) { + return "#dc3545"; // red + } + if (pendingCount > 0) { + return "#ffc107"; // yellow + } + return "#28a745"; // green + }; + + const getStatusIcon = (): string => { + if (!isOnline) { + return "\u25CB"; // hollow circle + } + if (isSyncing) { + return "\u21BB"; // rotating arrows + } + if (status === SyncStatus.Error) { + return "\u2717"; // cross mark + } + if (pendingCount > 0) { + return "\u25D4"; // partial circle + } + return "\u2713"; // check mark + }; + + return ( + <div + data-testid="sync-status-indicator" + style={{ + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.25rem 0.5rem", + borderRadius: "4px", + backgroundColor: "#f8f9fa", + border: "1px solid #dee2e6", + fontSize: "0.875rem", + }} + title={lastError || undefined} + > + <span + style={{ + color: getStatusColor(), + fontWeight: "bold", + }} + aria-hidden="true" + > + {getStatusIcon()} + </span> + <span style={{ color: getStatusColor() }}>{getStatusText()}</span> + </div> + ); +} diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 9b97620..12a09a9 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -1 +1,2 @@ export { ProtectedRoute } from "./ProtectedRoute"; +export { SyncStatusIndicator } from "./SyncStatusIndicator"; diff --git a/src/client/main.tsx b/src/client/main.tsx index 5a12f68..bff0889 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; -import { AuthProvider } from "./stores"; +import { AuthProvider, SyncProvider } from "./stores"; const rootElement = document.getElementById("root"); if (!rootElement) { @@ -11,7 +11,9 @@ if (!rootElement) { createRoot(rootElement).render( <StrictMode> <AuthProvider> - <App /> + <SyncProvider> + <App /> + </SyncProvider> </AuthProvider> </StrictMode>, ); diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 4dd4a81..f75f88d 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -1,13 +1,14 @@ /** * @vitest-environment jsdom */ +import "fake-indexeddb/auto"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { apiClient } from "../api/client"; -import { AuthProvider } from "../stores"; +import { AuthProvider, SyncProvider } from "../stores"; import { HomePage } from "./HomePage"; vi.mock("../api/client", () => ({ @@ -89,7 +90,9 @@ function renderWithProviders(path = "/") { return render( <Router hook={hook}> <AuthProvider> - <HomePage /> + <SyncProvider> + <HomePage /> + </SyncProvider> </AuthProvider> </Router>, ); diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index fb13422..debc935 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -4,6 +4,7 @@ import { ApiClientError, apiClient } from "../api"; import { CreateDeckModal } from "../components/CreateDeckModal"; import { DeleteDeckModal } from "../components/DeleteDeckModal"; import { EditDeckModal } from "../components/EditDeckModal"; +import { SyncStatusIndicator } from "../components/SyncStatusIndicator"; import { useAuth } from "../stores"; interface Deck { @@ -70,9 +71,12 @@ export function HomePage() { }} > <h1>Kioku</h1> - <button type="button" onClick={logout}> - Logout - </button> + <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> + <SyncStatusIndicator /> + <button type="button" onClick={logout}> + Logout + </button> + </div> </header> <main> 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 }) => ( + <SyncProvider>{children}</SyncProvider> +); + +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<unknown>; + 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<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; +} diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts index 7f86953..211f410 100644 --- a/src/client/sync/conflict.test.ts +++ b/src/client/sync/conflict.test.ts @@ -466,7 +466,8 @@ describe("ConflictResolver", () => { expect(result.decks).toHaveLength(1); expect(result.decks[0]?.resolution).toBe("server_wins"); - const insertedDeck = await localDeckRepository.findById("non-existent-deck"); + const insertedDeck = + await localDeckRepository.findById("non-existent-deck"); expect(insertedDeck?.name).toBe("Server Deck"); }); diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts index 365ef3c..4e0e3ef 100644 --- a/src/client/sync/conflict.ts +++ b/src/client/sync/conflict.ts @@ -1,8 +1,5 @@ import type { LocalCard, LocalDeck } from "../db/index"; -import { - localCardRepository, - localDeckRepository, -} from "../db/repositories"; +import { localCardRepository, localDeckRepository } from "../db/repositories"; import type { ServerCard, ServerDeck, SyncPullResult } from "./pull"; import type { SyncPushResult } from "./push"; @@ -39,10 +36,7 @@ export interface ConflictResolverOptions { * Compare timestamps for LWW resolution * Returns true if server data is newer or equal */ -function isServerNewer( - serverUpdatedAt: Date, - localUpdatedAt: Date, -): boolean { +function isServerNewer(serverUpdatedAt: Date, localUpdatedAt: Date): boolean { return serverUpdatedAt.getTime() >= localUpdatedAt.getTime(); } @@ -222,7 +216,10 @@ export class ConflictResolver { const serverDeck = pullResult.decks.find((d) => d.id === deckId); if (localDeck && serverDeck) { - const resolution = await this.resolveDeckConflict(localDeck, serverDeck); + const resolution = await this.resolveDeckConflict( + localDeck, + serverDeck, + ); result.decks.push(resolution); } else if (serverDeck) { // Local doesn't exist, apply server data @@ -239,7 +236,10 @@ export class ConflictResolver { const serverCard = pullResult.cards.find((c) => c.id === cardId); if (localCard && serverCard) { - const resolution = await this.resolveCardConflict(localCard, serverCard); + const resolution = await this.resolveCardConflict( + localCard, + serverCard, + ); result.cards.push(resolution); } else if (serverCard) { // Local doesn't exist, apply server data @@ -266,4 +266,6 @@ export function createConflictResolver( /** * Default conflict resolver using LWW (server wins) strategy */ -export const conflictResolver = new ConflictResolver({ strategy: "server_wins" }); +export const conflictResolver = new ConflictResolver({ + strategy: "server_wins", +}); diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts index a602753..c3ddab4 100644 --- a/src/client/sync/index.ts +++ b/src/client/sync/index.ts @@ -1,50 +1,47 @@ export { - SyncQueue, - SyncStatus, - syncQueue, - type PendingChanges, - type SyncQueueListener, - type SyncQueueState, - type SyncStatusType, -} from "./queue"; - + type ConflictResolutionItem, + type ConflictResolutionResult, + ConflictResolver, + type ConflictResolverOptions, + conflictResolver, + createConflictResolver, +} from "./conflict"; export { - createPushService, - pendingChangesToPushData, - PushService, - type PushServiceOptions, - type SyncCardData, - type SyncDeckData, - type SyncPushData, - type SyncPushResult, - type SyncReviewLogData, -} from "./push"; + createSyncManager, + SyncManager, + type SyncManagerEvent, + type SyncManagerListener, + type SyncManagerOptions, + type SyncResult, +} from "./manager"; export { createPullService, - pullResultToLocalData, PullService, type PullServiceOptions, + pullResultToLocalData, type ServerCard, type ServerDeck, type ServerReviewLog, type SyncPullResult, } from "./pull"; - export { - ConflictResolver, - conflictResolver, - createConflictResolver, - type ConflictResolutionItem, - type ConflictResolutionResult, - type ConflictResolverOptions, -} from "./conflict"; - + createPushService, + PushService, + type PushServiceOptions, + pendingChangesToPushData, + type SyncCardData, + type SyncDeckData, + type SyncPushData, + type SyncPushResult, + type SyncReviewLogData, +} from "./push"; export { - createSyncManager, - SyncManager, - type SyncManagerEvent, - type SyncManagerListener, - type SyncManagerOptions, - type SyncResult, -} from "./manager"; + type PendingChanges, + SyncQueue, + type SyncQueueListener, + type SyncQueueState, + SyncStatus, + type SyncStatusType, + syncQueue, +} from "./queue"; diff --git a/src/client/sync/manager.test.ts b/src/client/sync/manager.test.ts index 1e53bd4..96fb97d 100644 --- a/src/client/sync/manager.test.ts +++ b/src/client/sync/manager.test.ts @@ -8,8 +8,8 @@ import { describe, expect, it, - vi, type Mock, + vi, } from "vitest"; import { db } from "../db/index"; import { localDeckRepository } from "../db/repositories"; @@ -42,9 +42,8 @@ describe("SyncManager", () => { /** * Create a pending deck in the database that will need to be synced */ - async function createPendingDeck(id = "deck-1") { + async function createPendingDeck() { return localDeckRepository.create({ - id, userId: "user-1", name: "Test Deck", description: null, @@ -169,12 +168,10 @@ describe("SyncManager", () => { manager.start(); // Should only be called once for each event type - expect( - addSpy.mock.calls.filter((c) => c[0] === "online").length, - ).toBe(1); - expect( - addSpy.mock.calls.filter((c) => c[0] === "offline").length, - ).toBe(1); + expect(addSpy.mock.calls.filter((c) => c[0] === "online").length).toBe(1); + expect(addSpy.mock.calls.filter((c) => c[0] === "offline").length).toBe( + 1, + ); manager.stop(); addSpy.mockRestore(); @@ -354,20 +351,20 @@ describe("SyncManager", () => { it("should resolve conflicts when present", async () => { // Create pending data so pushToServer will be called - await createPendingDeck(); + const deck = await createPendingDeck(); const pushResult: SyncPushResult = { - decks: [{ id: "deck-1", syncVersion: 1 }], + decks: [{ id: deck.id, syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: ["deck-1"], cards: [] }, + conflicts: { decks: [deck.id], cards: [] }, }; pushToServer.mockResolvedValue(pushResult); const pullResult: SyncPullResult = { decks: [ { - id: "deck-1", + id: deck.id, userId: "user-1", name: "Server Deck", description: null, diff --git a/src/client/sync/manager.ts b/src/client/sync/manager.ts index d24fda4..d935a3b 100644 --- a/src/client/sync/manager.ts +++ b/src/client/sync/manager.ts @@ -239,8 +239,7 @@ export class SyncManager { pushResult, pullResult, ); - conflictsResolved = - resolution.decks.length + resolution.cards.length; + conflictsResolved = resolution.decks.length + resolution.cards.length; } const result: SyncResult = { diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts index 1aaac84..23c64ef 100644 --- a/src/client/sync/pull.test.ts +++ b/src/client/sync/pull.test.ts @@ -5,7 +5,7 @@ import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CardState, db, Rating } from "../db/index"; import { localCardRepository, localDeckRepository } from "../db/repositories"; -import { pullResultToLocalData, PullService } from "./pull"; +import { PullService, pullResultToLocalData } from "./pull"; import { SyncQueue } from "./queue"; describe("pullResultToLocalData", () => { @@ -68,7 +68,9 @@ describe("pullResultToLocalData", () => { currentSyncVersion: 3, }); - expect(result.decks[0]?.deletedAt).toEqual(new Date("2024-01-03T12:00:00Z")); + expect(result.decks[0]?.deletedAt).toEqual( + new Date("2024-01-03T12:00:00Z"), + ); }); it("should convert server cards to local format", () => { @@ -410,7 +412,9 @@ describe("PullService", () => { }); it("should throw error if pull fails", async () => { - const pullFromServer = vi.fn().mockRejectedValue(new Error("Network error")); + const pullFromServer = vi + .fn() + .mockRejectedValue(new Error("Network error")); const pullService = new PullService({ syncQueue, diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts index 79a9d4a..911a8d3 100644 --- a/src/client/sync/push.test.ts +++ b/src/client/sync/push.test.ts @@ -9,7 +9,7 @@ import { localDeckRepository, localReviewLogRepository, } from "../db/repositories"; -import { pendingChangesToPushData, PushService } from "./push"; +import { PushService, pendingChangesToPushData } from "./push"; import { SyncQueue } from "./queue"; describe("pendingChangesToPushData", () => { @@ -450,7 +450,9 @@ describe("PushService", () => { newCardsPerDay: 20, }); - const pushToServer = vi.fn().mockRejectedValue(new Error("Network error")); + const pushToServer = vi + .fn() + .mockRejectedValue(new Error("Network error")); const pushService = new PushService({ syncQueue, diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts index 7702583..2493e4e 100644 --- a/src/client/sync/push.ts +++ b/src/client/sync/push.ts @@ -129,7 +129,9 @@ function reviewLogToSyncData(log: LocalReviewLog): SyncReviewLogData { /** * Convert pending changes to sync push data format */ -export function pendingChangesToPushData(changes: PendingChanges): SyncPushData { +export function pendingChangesToPushData( + changes: PendingChanges, +): SyncPushData { return { decks: changes.decks.map(deckToSyncData), cards: changes.cards.map(cardToSyncData), diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts index d35ae32..f6a3019 100644 --- a/src/client/sync/queue.test.ts +++ b/src/client/sync/queue.test.ts @@ -230,13 +230,17 @@ describe("SyncQueue", () => { const state = await syncQueue.getState(); expect(state.lastSyncAt).not.toBeNull(); - expect(state.lastSyncAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(state.lastSyncAt?.getTime()).toBeGreaterThanOrEqual( + before.getTime(), + ); }); it("should persist state to localStorage", async () => { await syncQueue.completeSync(10); - const stored = JSON.parse(localStorage.getItem("kioku_sync_state") ?? "{}"); + const stored = JSON.parse( + localStorage.getItem("kioku_sync_state") ?? "{}", + ); expect(stored.lastSyncVersion).toBe(10); expect(stored.lastSyncAt).toBeDefined(); }); diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts index f0b112a..01c62cc 100644 --- a/src/client/sync/queue.ts +++ b/src/client/sync/queue.ts @@ -1,4 +1,9 @@ -import { db, type LocalCard, type LocalDeck, type LocalReviewLog } from "../db/index"; +import { + db, + type LocalCard, + type LocalDeck, + type LocalReviewLog, +} from "../db/index"; import { localCardRepository, localDeckRepository, @@ -41,7 +46,10 @@ const SYNC_STATE_KEY = "kioku_sync_state"; /** * Load sync state from localStorage */ -function loadSyncState(): Pick<SyncQueueState, "lastSyncVersion" | "lastSyncAt"> { +function loadSyncState(): Pick< + SyncQueueState, + "lastSyncVersion" | "lastSyncAt" +> { const stored = localStorage.getItem(SYNC_STATE_KEY); if (!stored) { return { lastSyncVersion: 0, lastSyncAt: null }; @@ -137,7 +145,9 @@ export class SyncQueue { */ async getPendingCount(): Promise<number> { const changes = await this.getPendingChanges(); - return changes.decks.length + changes.cards.length + changes.reviewLogs.length; + return ( + changes.decks.length + changes.cards.length + changes.reviewLogs.length + ); } /** @@ -205,17 +215,24 @@ export class SyncQueue { cards: { id: string; syncVersion: number }[]; reviewLogs: { id: string; syncVersion: number }[]; }): Promise<void> { - await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => { - for (const deck of results.decks) { - await localDeckRepository.markSynced(deck.id, deck.syncVersion); - } - for (const card of results.cards) { - await localCardRepository.markSynced(card.id, card.syncVersion); - } - for (const reviewLog of results.reviewLogs) { - await localReviewLogRepository.markSynced(reviewLog.id, reviewLog.syncVersion); - } - }); + await db.transaction( + "rw", + [db.decks, db.cards, db.reviewLogs], + async () => { + for (const deck of results.decks) { + await localDeckRepository.markSynced(deck.id, deck.syncVersion); + } + for (const card of results.cards) { + await localCardRepository.markSynced(card.id, card.syncVersion); + } + for (const reviewLog of results.reviewLogs) { + await localReviewLogRepository.markSynced( + reviewLog.id, + reviewLog.syncVersion, + ); + } + }, + ); await this.notifyListeners(); } @@ -227,17 +244,21 @@ export class SyncQueue { cards: LocalCard[]; reviewLogs: LocalReviewLog[]; }): Promise<void> { - await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => { - for (const deck of data.decks) { - await localDeckRepository.upsertFromServer(deck); - } - for (const card of data.cards) { - await localCardRepository.upsertFromServer(card); - } - for (const reviewLog of data.reviewLogs) { - await localReviewLogRepository.upsertFromServer(reviewLog); - } - }); + await db.transaction( + "rw", + [db.decks, db.cards, db.reviewLogs], + async () => { + for (const deck of data.decks) { + await localDeckRepository.upsertFromServer(deck); + } + for (const card of data.cards) { + await localCardRepository.upsertFromServer(card); + } + for (const reviewLog of data.reviewLogs) { + await localReviewLogRepository.upsertFromServer(reviewLog); + } + }, + ); await this.notifyListeners(); } diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 87acdb4..a1b6648 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -79,7 +79,10 @@ export interface SyncRepository { } export const syncRepository: SyncRepository = { - async pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult> { + async pushChanges( + userId: string, + data: SyncPushData, + ): Promise<SyncPushResult> { const result: SyncPushResult = { decks: [], cards: [], @@ -96,7 +99,11 @@ export const syncRepository: SyncRepository = { // Check if deck exists const existing = await db - .select({ id: decks.id, updatedAt: decks.updatedAt, syncVersion: decks.syncVersion }) + .select({ + id: decks.id, + updatedAt: decks.updatedAt, + syncVersion: decks.syncVersion, + }) .from(decks) .where(and(eq(decks.id, deckData.id), eq(decks.userId, userId))); @@ -118,7 +125,10 @@ export const syncRepository: SyncRepository = { .returning({ id: decks.id, syncVersion: decks.syncVersion }); if (inserted) { - result.decks.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + result.decks.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); } } else { const serverDeck = existing[0]; @@ -132,19 +142,27 @@ export const syncRepository: SyncRepository = { description: deckData.description, newCardsPerDay: deckData.newCardsPerDay, updatedAt: clientUpdatedAt, - deletedAt: deckData.deletedAt ? new Date(deckData.deletedAt) : null, + deletedAt: deckData.deletedAt + ? new Date(deckData.deletedAt) + : null, syncVersion: sql`${decks.syncVersion} + 1`, }) .where(eq(decks.id, deckData.id)) .returning({ id: decks.id, syncVersion: decks.syncVersion }); if (updated) { - result.decks.push({ id: updated.id, syncVersion: updated.syncVersion }); + result.decks.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); } } else if (serverDeck) { // Server wins - mark as conflict result.conflicts.decks.push(deckData.id); - result.decks.push({ id: serverDeck.id, syncVersion: serverDeck.syncVersion }); + result.decks.push({ + id: serverDeck.id, + syncVersion: serverDeck.syncVersion, + }); } } } @@ -166,7 +184,11 @@ export const syncRepository: SyncRepository = { // Check if card exists const existing = await db - .select({ id: cards.id, updatedAt: cards.updatedAt, syncVersion: cards.syncVersion }) + .select({ + id: cards.id, + updatedAt: cards.updatedAt, + syncVersion: cards.syncVersion, + }) .from(cards) .where(eq(cards.id, cardData.id)); @@ -187,7 +209,9 @@ export const syncRepository: SyncRepository = { scheduledDays: cardData.scheduledDays, reps: cardData.reps, lapses: cardData.lapses, - lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null, + lastReview: cardData.lastReview + ? new Date(cardData.lastReview) + : null, createdAt: new Date(cardData.createdAt), updatedAt: clientUpdatedAt, deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null, @@ -196,7 +220,10 @@ export const syncRepository: SyncRepository = { .returning({ id: cards.id, syncVersion: cards.syncVersion }); if (inserted) { - result.cards.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + result.cards.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); } } else { const serverCard = existing[0]; @@ -217,21 +244,31 @@ export const syncRepository: SyncRepository = { scheduledDays: cardData.scheduledDays, reps: cardData.reps, lapses: cardData.lapses, - lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null, + lastReview: cardData.lastReview + ? new Date(cardData.lastReview) + : null, updatedAt: clientUpdatedAt, - deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null, + deletedAt: cardData.deletedAt + ? new Date(cardData.deletedAt) + : null, syncVersion: sql`${cards.syncVersion} + 1`, }) .where(eq(cards.id, cardData.id)) .returning({ id: cards.id, syncVersion: cards.syncVersion }); if (updated) { - result.cards.push({ id: updated.id, syncVersion: updated.syncVersion }); + result.cards.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); } } else if (serverCard) { // Server wins - mark as conflict result.conflicts.cards.push(cardData.id); - result.cards.push({ id: serverCard.id, syncVersion: serverCard.syncVersion }); + result.cards.push({ + id: serverCard.id, + syncVersion: serverCard.syncVersion, + }); } } } @@ -272,16 +309,25 @@ export const syncRepository: SyncRepository = { durationMs: logData.durationMs, syncVersion: 1, }) - .returning({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion }); + .returning({ + id: reviewLogs.id, + syncVersion: reviewLogs.syncVersion, + }); if (inserted) { - result.reviewLogs.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + result.reviewLogs.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); } } else { // Already exists, return current version const existingLog = existing[0]; if (existingLog) { - result.reviewLogs.push({ id: existingLog.id, syncVersion: existingLog.syncVersion }); + result.reviewLogs.push({ + id: existingLog.id, + syncVersion: existingLog.syncVersion, + }); } } } @@ -289,14 +335,19 @@ export const syncRepository: SyncRepository = { return result; }, - async pullChanges(userId: string, query: SyncPullQuery): Promise<SyncPullResult> { + async pullChanges( + userId: string, + query: SyncPullQuery, + ): Promise<SyncPullResult> { const { lastSyncVersion } = query; // Get all decks with syncVersion > lastSyncVersion const pulledDecks = await db .select() .from(decks) - .where(and(eq(decks.userId, userId), gt(decks.syncVersion, lastSyncVersion))); + .where( + and(eq(decks.userId, userId), gt(decks.syncVersion, lastSyncVersion)), + ); // Get all cards from user's decks with syncVersion > lastSyncVersion const userDeckIds = await db @@ -321,7 +372,12 @@ export const syncRepository: SyncRepository = { const pulledReviewLogs = await db .select() .from(reviewLogs) - .where(and(eq(reviewLogs.userId, userId), gt(reviewLogs.syncVersion, lastSyncVersion))); + .where( + and( + eq(reviewLogs.userId, userId), + gt(reviewLogs.syncVersion, lastSyncVersion), + ), + ); // Calculate current max sync version across all entities let currentSyncVersion = lastSyncVersion; diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index 22efada..9deb5ac 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -196,7 +196,9 @@ describe("POST /api/sync/push", () => { const mockResult: SyncPushResult = { decks: [], cards: [], - reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }], + reviewLogs: [ + { id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }, + ], conflicts: { decks: [], cards: [] }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -217,7 +219,9 @@ describe("POST /api/sync/push", () => { expect(res.status).toBe(200); const body = (await res.json()) as SyncPushResponse; expect(body.reviewLogs).toHaveLength(1); - expect(body.reviewLogs?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440002"); + expect(body.reviewLogs?.[0]?.id).toBe( + "550e8400-e29b-41d4-a716-446655440002", + ); }); it("returns conflicts when server data is newer", async () => { @@ -250,7 +254,9 @@ describe("POST /api/sync/push", () => { expect(res.status).toBe(200); const body = (await res.json()) as SyncPushResponse; - expect(body.conflicts?.decks).toContain("550e8400-e29b-41d4-a716-446655440003"); + expect(body.conflicts?.decks).toContain( + "550e8400-e29b-41d4-a716-446655440003", + ); }); it("validates deck schema", async () => { @@ -380,7 +386,9 @@ describe("POST /api/sync/push", () => { const mockResult: SyncPushResult = { decks: [{ id: "550e8400-e29b-41d4-a716-446655440004", syncVersion: 1 }], cards: [{ id: "550e8400-e29b-41d4-a716-446655440005", syncVersion: 1 }], - reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 }], + reviewLogs: [ + { id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 }, + ], conflicts: { decks: [], cards: [] }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -649,7 +657,9 @@ describe("GET /api/sync/pull", () => { expect(res.status).toBe(200); const body = (await res.json()) as SyncPullResponse; expect(body.reviewLogs).toHaveLength(1); - expect(body.reviewLogs?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440002"); + expect(body.reviewLogs?.[0]?.id).toBe( + "550e8400-e29b-41d4-a716-446655440002", + ); expect(body.reviewLogs?.[0]?.rating).toBe(3); expect(body.reviewLogs?.[0]?.durationMs).toBe(5000); }); |
