diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:41:28 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:41:28 +0900 |
| commit | f0635f9beb0ff85bbea2264a1b19160f0beed257 (patch) | |
| tree | 8e7be36100a8de5dcd7301c5e7be91f2ddfdbc0c | |
| parent | 445781bc40afee2c64f645abcfa2575b4218aa08 (diff) | |
| download | kioku-f0635f9beb0ff85bbea2264a1b19160f0beed257.tar.gz kioku-f0635f9beb0ff85bbea2264a1b19160f0beed257.tar.zst kioku-f0635f9beb0ff85bbea2264a1b19160f0beed257.zip | |
feat(client): add offline mode banner indicator
Displays a prominent banner at the top of all pages when the user
is offline, informing them that changes will sync when reconnected.
Shows pending change count when applicable.
🤖 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.tsx | 43 | ||||
| -rw-r--r-- | src/client/components/OfflineBanner.test.tsx | 85 | ||||
| -rw-r--r-- | src/client/components/OfflineBanner.tsx | 39 | ||||
| -rw-r--r-- | src/client/components/index.ts | 1 |
5 files changed, 149 insertions, 21 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index ac0760e..c9c000f 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -165,7 +165,7 @@ Smaller features first to enable early MVP validation. ### Sync UI - [x] Sync status indicator - [x] Manual sync button -- [ ] Offline mode indicator +- [x] Offline mode indicator **✅ Milestone**: Study offline and sync when back online diff --git a/src/client/App.tsx b/src/client/App.tsx index f774003..3c20c54 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,5 +1,5 @@ import { Route, Switch } from "wouter"; -import { ProtectedRoute } from "./components"; +import { OfflineBanner, ProtectedRoute } from "./components"; import { DeckDetailPage, HomePage, @@ -10,24 +10,27 @@ import { export function App() { return ( - <Switch> - <Route path="/"> - <ProtectedRoute> - <HomePage /> - </ProtectedRoute> - </Route> - <Route path="/decks/:deckId"> - <ProtectedRoute> - <DeckDetailPage /> - </ProtectedRoute> - </Route> - <Route path="/decks/:deckId/study"> - <ProtectedRoute> - <StudyPage /> - </ProtectedRoute> - </Route> - <Route path="/login" component={LoginPage} /> - <Route component={NotFoundPage} /> - </Switch> + <> + <OfflineBanner /> + <Switch> + <Route path="/"> + <ProtectedRoute> + <HomePage /> + </ProtectedRoute> + </Route> + <Route path="/decks/:deckId"> + <ProtectedRoute> + <DeckDetailPage /> + </ProtectedRoute> + </Route> + <Route path="/decks/:deckId/study"> + <ProtectedRoute> + <StudyPage /> + </ProtectedRoute> + </Route> + <Route path="/login" component={LoginPage} /> + <Route component={NotFoundPage} /> + </Switch> + </> ); } diff --git a/src/client/components/OfflineBanner.test.tsx b/src/client/components/OfflineBanner.test.tsx new file mode 100644 index 0000000..41679d9 --- /dev/null +++ b/src/client/components/OfflineBanner.test.tsx @@ -0,0 +1,85 @@ +/** + * @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 { OfflineBanner } from "./OfflineBanner"; + +// Mock the useSync hook +const mockUseSync = vi.fn(); +vi.mock("../stores", () => ({ + useSync: () => mockUseSync(), +})); + +describe("OfflineBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders nothing when online", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + pendingCount: 0, + }); + + render(<OfflineBanner />); + + expect(screen.queryByTestId("offline-banner")).toBeNull(); + }); + + it("renders banner when offline", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + pendingCount: 0, + }); + + render(<OfflineBanner />); + + const banner = screen.getByTestId("offline-banner"); + expect(banner).toBeDefined(); + expect( + screen.getByText(/You're offline. Changes will sync when you reconnect./), + ).toBeDefined(); + }); + + it("displays pending count when offline with pending changes", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + pendingCount: 5, + }); + + render(<OfflineBanner />); + + expect(screen.getByTestId("offline-pending-count")).toBeDefined(); + expect(screen.getByText("(5 pending)")).toBeDefined(); + }); + + it("does not display pending count when there are no pending changes", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + pendingCount: 0, + }); + + render(<OfflineBanner />); + + expect(screen.queryByTestId("offline-pending-count")).toBeNull(); + }); + + it("has correct accessibility attributes", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + pendingCount: 0, + }); + + render(<OfflineBanner />); + + const banner = screen.getByTestId("offline-banner"); + expect(banner.getAttribute("role")).toBe("status"); + expect(banner.getAttribute("aria-live")).toBe("polite"); + }); +}); diff --git a/src/client/components/OfflineBanner.tsx b/src/client/components/OfflineBanner.tsx new file mode 100644 index 0000000..357db33 --- /dev/null +++ b/src/client/components/OfflineBanner.tsx @@ -0,0 +1,39 @@ +import { useSync } from "../stores"; + +export function OfflineBanner() { + const { isOnline, pendingCount } = useSync(); + + if (isOnline) { + return null; + } + + return ( + <div + data-testid="offline-banner" + role="status" + aria-live="polite" + style={{ + backgroundColor: "#6c757d", + color: "white", + padding: "0.5rem 1rem", + textAlign: "center", + fontSize: "0.875rem", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "0.5rem", + }} + > + <span aria-hidden="true">⚡</span> + <span> + You're offline. Changes will sync when you reconnect. + {pendingCount > 0 && ( + <span data-testid="offline-pending-count"> + {" "} + ({pendingCount} pending) + </span> + )} + </span> + </div> + ); +} diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 31ebe1f..10f31c6 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -1,3 +1,4 @@ +export { OfflineBanner } from "./OfflineBanner"; export { ProtectedRoute } from "./ProtectedRoute"; export { SyncButton } from "./SyncButton"; export { SyncStatusIndicator } from "./SyncStatusIndicator"; |
