aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 23:34:03 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 23:34:03 +0900
commit0c042ac89fc0822fcbe09c48702857faa5494ae1 (patch)
treeea1f1d180f747613343040d441a07f92b2760840 /src/client/components
parentae5a0bb97fbf013417a6962f7e077f0408b2a951 (diff)
downloadkioku-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.tsx160
-rw-r--r--src/client/components/SyncStatusIndicator.tsx82
-rw-r--r--src/client/components/index.ts1
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";