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 /src/client/components | |
| 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>
Diffstat (limited to 'src/client/components')
| -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 |
3 files changed, 243 insertions, 0 deletions
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"; |
