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/sync | |
| 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/sync')
| -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 |
10 files changed, 125 insertions, 96 deletions
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(); } |
