aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 23:41:28 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 23:41:28 +0900
commitf0635f9beb0ff85bbea2264a1b19160f0beed257 (patch)
tree8e7be36100a8de5dcd7301c5e7be91f2ddfdbc0c
parent445781bc40afee2c64f645abcfa2575b4218aa08 (diff)
downloadkioku-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.md2
-rw-r--r--src/client/App.tsx43
-rw-r--r--src/client/components/OfflineBanner.test.tsx85
-rw-r--r--src/client/components/OfflineBanner.tsx39
-rw-r--r--src/client/components/index.ts1
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";